diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 91ce228..d0b8ee7 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `ce7b7c333c5b287456ad4a19c90a4e9b4ee02dcf`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `55011066f84f073ceabb35c99ddd5b65745e34f6`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 8538995..5003d6f 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "ce7b7c333c5b287456ad4a19c90a4e9b4ee02dcf", + "sourceRevision": "55011066f84f073ceabb35c99ddd5b65745e34f6", "files": [ { "path": ".dockerignore", @@ -2472,6 +2472,11 @@ "sha256": "f42836af5cf674c73824ab753984defc4094b3112c9f6709eef56795f45ab23d", "bytes": 2992 }, + { + "path": "scripts/generate_moby_v1_golden.py", + "sha256": "a0b634a41d9266412abd4a6193bb171d48d0256dba23140cd5617366e562b930", + "bytes": 6874 + }, { "path": "scripts/generate_sbom.py", "sha256": "aa5aecf22ba335e80cffcf2a7fb21b99f1b3370ed4d8706158725c07203b5b44", @@ -3052,6 +3057,16 @@ "sha256": "006648857d313b8f7fcaa62b86d5f453aa565a75a348aefe7b8557e3b4ba8620", "bytes": 63 }, + { + "path": "tests/fixtures/moby_v1_golden.json", + "sha256": "79e78870bad30b6ceee4bbd4b62243842f3aee73a3a2c8c5d61d9f301f7c61a7", + "bytes": 9814 + }, + { + "path": "tests/fixtures/moby_v1_reference/main.go", + "sha256": "b700de1bf2e0d9414c388e9d4b61850e53dfe08a90497f592f45caff71eebbdd", + "bytes": 2765 + }, { "path": "tests/fixtures/projectbrain-managed-validation-browser.yml", "sha256": "6baaf4ceb2e493f90cc8210953a92f8f2c6da723e9c85446f38670efa285d9cc", @@ -3364,8 +3379,8 @@ }, { "path": "tests/test_mcp_hub_release_images.py", - "sha256": "c44fb08a07c73164769f97b70f42b5fc412de0ef85ee4a9af62a28b0bfccfd82", - "bytes": 24244 + "sha256": "38cefc6b3d12f8c197ae522c5bd6ecc9e20407bb3be2dca7fe593a87229eecca", + "bytes": 31898 }, { "path": "tests/test_mcp_workflow_prompts.py", diff --git a/scripts/generate_moby_v1_golden.py b/scripts/generate_moby_v1_golden.py new file mode 100644 index 0000000..a079a0f --- /dev/null +++ b/scripts/generate_moby_v1_golden.py @@ -0,0 +1,174 @@ +"""Regenerate test-only V1 wire goldens with Go and source-pinned Moby structs. + +No Hub normalizer code is imported. This installs nothing, builds no image and +changes only the fixed test fixture. Go 1.27.2 is a local regeneration tool. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import tempfile +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests/fixtures/moby_v1_golden.json" +REFERENCE = ROOT / "tests/fixtures/moby_v1_reference/main.go" +PINS = ( + ( + "Old", + "onbuild_null", + "89c5e8fd66634b6128fc4c0e6f1236e2540e46e0", + "image/image.go", + "5ce9ea9e6daeb5cecf03dbfafe7c895516da782010e2797b1fc0f979161cf5be", + "08df9dce6147eb2a4edb9859d81fedbdb13f2af2e249db38bf79361f67db4bcf", + ), + ( + "New", + "onbuild_omitempty", + "5834c866f83c0bae2d0be28f38b92f32cd07ca38", + "daemon/internal/image/image.go", + "c186304737e0343a04e0f9a4e88f8f81b867e86159cd7bf68dd0d35a8392d3a8", + "06f5e71501399a33906ef7b006d49d71a700e6c84d7442a90e9d240cb52b81ee", + ), +) + + +def source(commit: str, path: str, expected: str) -> str: + url = f"https://raw.githubusercontent.com/moby/moby/{commit}/{path}" + with urllib.request.build_opener(urllib.request.ProxyHandler({})).open( + url, timeout=20 + ) as response: + data = response.read(200_001) + if ( + not isinstance(data, bytes) + or len(data) > 200_000 + or hashlib.sha256(data).hexdigest() != expected + ): + raise ValueError("pinned reference source digest mismatch") + return data.decode("utf-8") + + +def declaration(text: str, original: str, replacement: str) -> str: + match = re.search(rf"(?ms)^type {original} struct \{{\n.*?^\}}", text) + if not match: + raise ValueError("pinned reference struct is missing") + matched_source = match.group() + if not isinstance(matched_source, str): + raise ValueError("pinned reference declaration is not text") + return matched_source.replace(f"type {original} struct", f"type {replacement} struct", 1) + + +def generate(go: Path) -> None: + if not go.is_absolute() or not go.is_file(): + raise ValueError("an existing absolute Go executable is required") + env = dict(os.environ) + env.update( + GOTOOLCHAIN="local", + GOPROXY="off", + GOSUMDB="off", + GO111MODULE="off", + GOMAXPROCS="2", + GOFLAGS="-p=2", + CGO_ENABLED="0", + ) + version = subprocess.run( # noqa: S603 - explicit existing absolute regeneration compiler + [str(go), "version"], check=True, capture_output=True, text=True, env=env + ) + if version.stdout.split()[2] != "go1.27.2": + raise ValueError("reference regeneration requires Go 1.27.2") + # These are public synthetic inputs: no runtime config, credentials or image + # bytes are sent to the reference compiler or persisted in this fixture. + inputs = [ + { + "architecture": "amd64", + "os": "linux", + "created": "2026-10-14T00:01:02.123456789Z", + "container_config": { + "User": "constructor", + "Cmd": ["µ<&>\u2028\u2029"], + "Labels": {"z": "last", "a": "first"}, + "StopTimeout": 0, + }, + "config": { + "User": "10001:10001", + "Cmd": ["reference"], + "Labels": {"z": "µ<&>\u2028\u2029", "a": "first"}, + "StopTimeout": 0, + "Healthcheck": {"Test": ["CMD", "true"], "Interval": 30000000000, "Retries": 2}, + }, + "rootfs": {"type": "layers", "diff_ids": ["sha256:" + "a" * 64, "sha256:" + "b" * 64]}, + }, + { + "architecture": "amd64", + "os": "linux", + "config": {"User": "10001:10001"}, + "rootfs": {"type": "layers", "diff_ids": ["sha256:" + "c" * 64]}, + }, + ] + declarations = [ + 'package main\nimport "time"\n', + """type HealthConfig struct { +Test []string `json:",omitempty"` +Interval time.Duration `json:",omitempty"` +Timeout time.Duration `json:",omitempty"` +StartPeriod time.Duration `json:",omitempty"` +StartInterval time.Duration `json:",omitempty"` +Retries int `json:",omitempty"` +}\n""", + ] + for label, _dialect, commit, image_path, image_hash, config_hash in PINS: + config = declaration( + source(commit, "api/types/container/config.go", config_hash), "Config", label + "Config" + ) + config = ( + config.replace("strslice.StrSlice", "[]string") + .replace("nat.PortSet", "map[string]struct{}") + .replace("network.PortSet", "map[string]struct{}") + ) + image = declaration(source(commit, image_path, image_hash), "V1Image", label + "V1") + declarations.extend((config, image.replace("container.Config", label + "Config"))) + local = ROOT / ".luna" + local.mkdir(exist_ok=True) + if local.is_symlink() or not local.resolve().is_relative_to(ROOT.resolve()): + raise ValueError("reference scratch directory must stay in this checkout") + with tempfile.TemporaryDirectory(prefix="moby-v1-reference-", dir=local) as name: + temp = Path(name) + if not temp.resolve().is_relative_to(local.resolve()): + raise ValueError("reference temporary directory escaped its owner") + env["GOCACHE"] = str(temp / "cache") + (temp / "main.go").write_bytes(REFERENCE.read_bytes()) + (temp / "types.go").write_text("\n".join(declarations), encoding="utf-8") + (temp / "inputs.json").write_text(json.dumps(inputs, ensure_ascii=False), encoding="utf-8") + completed = subprocess.run( # noqa: S603 - fixed standard-library-only reference program + [str(go), "run", ".", str(temp / "inputs.json")], + cwd=temp, + env=env, + stdin=subprocess.DEVNULL, + check=True, + capture_output=True, + timeout=180, + ) + if len(completed.stdout) > 100_000: + raise ValueError("reference output exceeded its fixed bound") + cases = json.loads(completed.stdout) + result = { + "schema": "itworx.moby-v1-wire-golden/v1", + "goVersion": "go1.27.2", + "referenceMainSha256": hashlib.sha256(REFERENCE.read_bytes()).hexdigest(), + "sourceCommits": [pin[2] for pin in PINS], + "cases": cases, + } + FIXTURE.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Generated {len(cases)} independent Go reference cases") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--go", type=Path, required=True) + generate(parser.parse_args().go) diff --git a/tests/fixtures/moby_v1_golden.json b/tests/fixtures/moby_v1_golden.json new file mode 100644 index 0000000..6a23fe7 --- /dev/null +++ b/tests/fixtures/moby_v1_golden.json @@ -0,0 +1,165 @@ +{ + "schema": "itworx.moby-v1-wire-golden/v1", + "goVersion": "go1.27.2", + "referenceMainSha256": "b700de1bf2e0d9414c388e9d4b61850e53dfe08a90497f592f45caff71eebbdd", + "sourceCommits": [ + "89c5e8fd66634b6128fc4c0e6f1236e2540e46e0", + "5834c866f83c0bae2d0be28f38b92f32cd07ca38" + ], + "cases": [ + { + "dialect": "onbuild_null", + "config": { + "architecture": "amd64", + "os": "linux", + "created": "2026-10-14T00:01:02.123456789Z", + "container_config": { + "User": "constructor", + "Cmd": [ + "µ<&>

" + ], + "Labels": { + "z": "last", + "a": "first" + }, + "StopTimeout": 0 + }, + "config": { + "User": "10001:10001", + "Cmd": [ + "reference" + ], + "Labels": { + "z": "µ<&>

", + "a": "first" + }, + "StopTimeout": 0, + "Healthcheck": { + "Test": [ + "CMD", + "true" + ], + "Interval": 30000000000, + "Retries": 2 + } + }, + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } + }, + "blobs": [ + { + "name": "blobs/sha256/e9e75851959057c9547c9692b88370291d478cbc2c4ef5752074971970f36b3c", + "payloadBase64": "eyJpZCI6IjRiZDY5YzRjYTE1OWQ4NzI0MGJmZTVlZTViM2ZmNjliZjUwZWY3YWJkMGQzZDc4NjA4MTgyNjJlZDU2Y2ViZGIiLCJjcmVhdGVkIjoiMTk3MC0wMS0wMVQwMDowMDowMFoiLCJjb250YWluZXJfY29uZmlnIjp7Ikhvc3RuYW1lIjoiIiwiRG9tYWlubmFtZSI6IiIsIlVzZXIiOiIiLCJBdHRhY2hTdGRpbiI6ZmFsc2UsIkF0dGFjaFN0ZG91dCI6ZmFsc2UsIkF0dGFjaFN0ZGVyciI6ZmFsc2UsIlR0eSI6ZmFsc2UsIk9wZW5TdGRpbiI6ZmFsc2UsIlN0ZGluT25jZSI6ZmFsc2UsIkVudiI6bnVsbCwiQ21kIjpudWxsLCJJbWFnZSI6IiIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJPbkJ1aWxkIjpudWxsLCJMYWJlbHMiOm51bGx9LCJvcyI6ImxpbnV4In0=" + }, + { + "name": "blobs/sha256/777fb43341dc2ede2a03d9c8e9d79f15d64a4be734f6f720e0a36653cf7d644b", + "payloadBase64": "eyJpZCI6ImRkZDkyMDY4YWNlMzA3NGQxY2NlYTZhNDE0ODAwMWUxYTIyYjY4M2FiZWM4NmViMTUyNjMwMzk5YThhZWY4YzkiLCJwYXJlbnQiOiI0YmQ2OWM0Y2ExNTlkODcyNDBiZmU1ZWU1YjNmZjY5YmY1MGVmN2FiZDBkM2Q3ODYwODE4MjYyZWQ1NmNlYmRiIiwiY3JlYXRlZCI6IjIwMjYtMTAtMTRUMDA6MDE6MDIuMTIzNDU2Nzg5WiIsImNvbnRhaW5lcl9jb25maWciOnsiSG9zdG5hbWUiOiIiLCJEb21haW5uYW1lIjoiIiwiVXNlciI6ImNvbnN0cnVjdG9yIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOm51bGwsIkNtZCI6WyLCtVx1MDAzY1x1MDAyNlx1MDAzZVx1MjAyOFx1MjAyOSJdLCJJbWFnZSI6IiIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJPbkJ1aWxkIjpudWxsLCJMYWJlbHMiOnsiYSI6ImZpcnN0IiwieiI6Imxhc3QifSwiU3RvcFRpbWVvdXQiOjB9LCJjb25maWciOnsiSG9zdG5hbWUiOiIiLCJEb21haW5uYW1lIjoiIiwiVXNlciI6IjEwMDAxOjEwMDAxIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOm51bGwsIkNtZCI6WyJyZWZlcmVuY2UiXSwiSGVhbHRoY2hlY2siOnsiVGVzdCI6WyJDTUQiLCJ0cnVlIl0sIkludGVydmFsIjozMDAwMDAwMDAwMCwiUmV0cmllcyI6Mn0sIkltYWdlIjoiIiwiVm9sdW1lcyI6bnVsbCwiV29ya2luZ0RpciI6IiIsIkVudHJ5cG9pbnQiOm51bGwsIk9uQnVpbGQiOm51bGwsIkxhYmVscyI6eyJhIjoiZmlyc3QiLCJ6IjoiwrVcdTAwM2NcdTAwMjZcdTAwM2VcdTIwMjhcdTIwMjkifSwiU3RvcFRpbWVvdXQiOjB9LCJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsIm9zIjoibGludXgifQ==" + } + ] + }, + { + "dialect": "onbuild_omitempty", + "config": { + "architecture": "amd64", + "os": "linux", + "created": "2026-10-14T00:01:02.123456789Z", + "container_config": { + "User": "constructor", + "Cmd": [ + "µ<&>

" + ], + "Labels": { + "z": "last", + "a": "first" + }, + "StopTimeout": 0 + }, + "config": { + "User": "10001:10001", + "Cmd": [ + "reference" + ], + "Labels": { + "z": "µ<&>

", + "a": "first" + }, + "StopTimeout": 0, + "Healthcheck": { + "Test": [ + "CMD", + "true" + ], + "Interval": 30000000000, + "Retries": 2 + } + }, + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } + }, + "blobs": [ + { + "name": "blobs/sha256/cab874d9c7a96fe390da5177f6b0beaa00ba368a72269d8e566b0274b9ea16c6", + "payloadBase64": "eyJpZCI6IjdhOWExYmE1MTFjYTkwYWZkODUxM2YyNzVhMGJkNWRjODNjZWY2NDJjMjkzYjUyZTYxYTkzNTBjZThiMzk0OTMiLCJjcmVhdGVkIjoiMTk3MC0wMS0wMVQwMDowMDowMFoiLCJjb250YWluZXJfY29uZmlnIjp7Ikhvc3RuYW1lIjoiIiwiRG9tYWlubmFtZSI6IiIsIlVzZXIiOiIiLCJBdHRhY2hTdGRpbiI6ZmFsc2UsIkF0dGFjaFN0ZG91dCI6ZmFsc2UsIkF0dGFjaFN0ZGVyciI6ZmFsc2UsIlR0eSI6ZmFsc2UsIk9wZW5TdGRpbiI6ZmFsc2UsIlN0ZGluT25jZSI6ZmFsc2UsIkVudiI6bnVsbCwiQ21kIjpudWxsLCJJbWFnZSI6IiIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJMYWJlbHMiOm51bGx9LCJvcyI6ImxpbnV4In0=" + }, + { + "name": "blobs/sha256/f51881ca0e4436a2ca17e4d94fdc08590cabe3aa345c5ae290f899869ab824ba", + "payloadBase64": "eyJpZCI6IjNiMWFhZTBhMjU4NmU0MWI1YzdiN2UzODdhYTQxNjYxOWU5YjBlYWRmMTU4Nzg1OTE3ZDJlMGMxY2ZlNzVmYzciLCJwYXJlbnQiOiI3YTlhMWJhNTExY2E5MGFmZDg1MTNmMjc1YTBiZDVkYzgzY2VmNjQyYzI5M2I1MmU2MWE5MzUwY2U4YjM5NDkzIiwiY3JlYXRlZCI6IjIwMjYtMTAtMTRUMDA6MDE6MDIuMTIzNDU2Nzg5WiIsImNvbnRhaW5lcl9jb25maWciOnsiSG9zdG5hbWUiOiIiLCJEb21haW5uYW1lIjoiIiwiVXNlciI6ImNvbnN0cnVjdG9yIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOm51bGwsIkNtZCI6WyLCtVx1MDAzY1x1MDAyNlx1MDAzZVx1MjAyOFx1MjAyOSJdLCJJbWFnZSI6IiIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJMYWJlbHMiOnsiYSI6ImZpcnN0IiwieiI6Imxhc3QifSwiU3RvcFRpbWVvdXQiOjB9LCJjb25maWciOnsiSG9zdG5hbWUiOiIiLCJEb21haW5uYW1lIjoiIiwiVXNlciI6IjEwMDAxOjEwMDAxIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOm51bGwsIkNtZCI6WyJyZWZlcmVuY2UiXSwiSGVhbHRoY2hlY2siOnsiVGVzdCI6WyJDTUQiLCJ0cnVlIl0sIkludGVydmFsIjozMDAwMDAwMDAwMCwiUmV0cmllcyI6Mn0sIkltYWdlIjoiIiwiVm9sdW1lcyI6bnVsbCwiV29ya2luZ0RpciI6IiIsIkVudHJ5cG9pbnQiOm51bGwsIkxhYmVscyI6eyJhIjoiZmlyc3QiLCJ6IjoiwrVcdTAwM2NcdTAwMjZcdTAwM2VcdTIwMjhcdTIwMjkifSwiU3RvcFRpbWVvdXQiOjB9LCJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsIm9zIjoibGludXgifQ==" + } + ] + }, + { + "dialect": "onbuild_null", + "config": { + "architecture": "amd64", + "os": "linux", + "config": { + "User": "10001:10001" + }, + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + } + }, + "blobs": [ + { + "name": "blobs/sha256/8d095090ae9bff4df0f104355b1d57c36f538a9ab088bd9962ecc042c84fbd2c", + "payloadBase64": "eyJpZCI6IjUzODdhM2U3NzM3YWRkMGFlOTRhZDZiZGM2ZmYwYzc5MjY5OTEyNDg1NGE3Y2MxNDgxM2FmNDViNzE5Yjk2OTAiLCJjcmVhdGVkIjpudWxsLCJjb250YWluZXJfY29uZmlnIjp7Ikhvc3RuYW1lIjoiIiwiRG9tYWlubmFtZSI6IiIsIlVzZXIiOiIiLCJBdHRhY2hTdGRpbiI6ZmFsc2UsIkF0dGFjaFN0ZG91dCI6ZmFsc2UsIkF0dGFjaFN0ZGVyciI6ZmFsc2UsIlR0eSI6ZmFsc2UsIk9wZW5TdGRpbiI6ZmFsc2UsIlN0ZGluT25jZSI6ZmFsc2UsIkVudiI6bnVsbCwiQ21kIjpudWxsLCJJbWFnZSI6IiIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJPbkJ1aWxkIjpudWxsLCJMYWJlbHMiOm51bGx9LCJjb25maWciOnsiSG9zdG5hbWUiOiIiLCJEb21haW5uYW1lIjoiIiwiVXNlciI6IjEwMDAxOjEwMDAxIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOm51bGwsIkNtZCI6bnVsbCwiSW1hZ2UiOiIiLCJWb2x1bWVzIjpudWxsLCJXb3JraW5nRGlyIjoiIiwiRW50cnlwb2ludCI6bnVsbCwiT25CdWlsZCI6bnVsbCwiTGFiZWxzIjpudWxsfSwiYXJjaGl0ZWN0dXJlIjoiYW1kNjQiLCJvcyI6ImxpbnV4In0=" + } + ] + }, + { + "dialect": "onbuild_omitempty", + "config": { + "architecture": "amd64", + "os": "linux", + "config": { + "User": "10001:10001" + }, + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + } + }, + "blobs": [ + { + "name": "blobs/sha256/466fa2f08fccd0fcd2e8debbdd88466c6a4e3e1d5d53df5f02abe0c96afe8cfb", + "payloadBase64": "eyJpZCI6ImIyYmMzM2I4MjJlYTYyYjI2ZGZlZjViZDUxYWU2Yjc0NDI5Y2JjYjY3OWEwMmE3ZDUwNzEzZDdhZTlmOTQxNTciLCJjcmVhdGVkIjpudWxsLCJjb250YWluZXJfY29uZmlnIjp7Ikhvc3RuYW1lIjoiIiwiRG9tYWlubmFtZSI6IiIsIlVzZXIiOiIiLCJBdHRhY2hTdGRpbiI6ZmFsc2UsIkF0dGFjaFN0ZG91dCI6ZmFsc2UsIkF0dGFjaFN0ZGVyciI6ZmFsc2UsIlR0eSI6ZmFsc2UsIk9wZW5TdGRpbiI6ZmFsc2UsIlN0ZGluT25jZSI6ZmFsc2UsIkVudiI6bnVsbCwiQ21kIjpudWxsLCJJbWFnZSI6IiIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJMYWJlbHMiOm51bGx9LCJjb25maWciOnsiSG9zdG5hbWUiOiIiLCJEb21haW5uYW1lIjoiIiwiVXNlciI6IjEwMDAxOjEwMDAxIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOm51bGwsIkNtZCI6bnVsbCwiSW1hZ2UiOiIiLCJWb2x1bWVzIjpudWxsLCJXb3JraW5nRGlyIjoiIiwiRW50cnlwb2ludCI6bnVsbCwiTGFiZWxzIjpudWxsfSwiYXJjaGl0ZWN0dXJlIjoiYW1kNjQiLCJvcyI6ImxpbnV4In0=" + } + ] + } + ] +} diff --git a/tests/fixtures/moby_v1_reference/main.go b/tests/fixtures/moby_v1_reference/main.go new file mode 100644 index 0000000..c3bfc14 --- /dev/null +++ b/tests/fixtures/moby_v1_reference/main.go @@ -0,0 +1,102 @@ +// Reference-only Moby saveConfig/CreateID oracle. The generator supplies the +// source-pinned Config/V1Image declarations; this never imports Hub code. +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "reflect" +) + +type blob struct { + Name string `json:"name"` + Payload string `json:"payloadBase64"` +} +type result struct { + Dialect string `json:"dialect"` + Config json.RawMessage `json:"config"` + Blobs []blob `json:"blobs"` +} + +func sum(data []byte) string { return fmt.Sprintf("%x", sha256.Sum256(data)) } +func encoded(value any) []byte { + data, err := json.Marshal(value) + if err != nil { + panic(err) + } + return data +} +func derive[T any](dialect string, raw json.RawMessage) result { + var image struct { + RootFS struct { + DiffIDs []string `json:"diff_ids"` + } `json:"rootfs"` + } + if err := json.Unmarshal(raw, &image); err != nil { + panic(err) + } + // Image.Parent shadows V1Image.Parent in Moby; these golden inputs do not + // depend on an old builder's parent field. + var original T + if err := json.Unmarshal(raw, &original); err != nil { + panic(err) + } + chain, parent := "", "" + output := result{Dialect: dialect, Config: raw, Blobs: []blob{}} + for index, diff := range image.RootFS.DiffIDs { + if chain == "" { + chain = diff + } else { + chain = "sha256:" + sum([]byte(chain+" "+diff)) + } + var legacy T + if index == len(image.RootFS.DiffIDs)-1 { + legacy = original + } else { + if err := json.Unmarshal([]byte(`{"created":"1970-01-01T00:00:00Z"}`), &legacy); err != nil { + panic(err) + } + } + reflected := reflect.ValueOf(&legacy).Elem() + reflected.FieldByName("ID").SetString("") + var preimage map[string]json.RawMessage + if err := json.Unmarshal(encoded(legacy), &preimage); err != nil { + panic(err) + } + preimage["layer_id"] = encoded(chain) + if parent != "" { + preimage["parent"] = encoded("sha256:" + parent) + } + identity := sum(encoded(preimage)) + reflected.FieldByName("ID").SetString(identity) + if parent != "" { + reflected.FieldByName("Parent").SetString(parent) + } + reflected.FieldByName("OS").SetString("linux") + payload := encoded(legacy) + output.Blobs = append(output.Blobs, blob{ + Name: "blobs/sha256/" + sum(payload), Payload: base64.StdEncoding.EncodeToString(payload), + }) + parent = identity + } + return output +} +func main() { + data, err := os.ReadFile(os.Args[1]) + if err != nil { + panic(err) + } + var inputs []json.RawMessage + if err := json.Unmarshal(data, &inputs); err != nil { + panic(err) + } + outputs := []result{} + for _, input := range inputs { + outputs = append(outputs, derive[OldV1]("onbuild_null", input)) + outputs = append(outputs, derive[NewV1]("onbuild_omitempty", input)) + } + fmt.Println(string(encoded(outputs))) +} diff --git a/tests/test_mcp_hub_release_images.py b/tests/test_mcp_hub_release_images.py index f7e5cf1..499fda9 100644 --- a/tests/test_mcp_hub_release_images.py +++ b/tests/test_mcp_hub_release_images.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import hashlib import io import json @@ -123,6 +124,8 @@ def _write_archive_fixture(path: Path, members: dict[str, bytes | None]) -> None def _docker29_oci_fixture( tmp_path: Path, + *, + layer_count: int = 1, ) -> tuple[Path, dict[str, bytes | None], dict[str, dict[str, str]], dict[str, str]]: tmp_path.mkdir(parents=True, exist_ok=True) revision = "a" * 40 @@ -143,6 +146,19 @@ def _docker29_oci_fixture( layer_digest = "sha256:" + hashlib.sha256(layer).hexdigest() layer_name = "blobs/sha256/" + layer_digest.split(":", 1)[1] members: dict[str, bytes | None] = {"blobs": None, "blobs/sha256": None, layer_name: layer} + layer_rows = [(layer_digest, layer_name, layer)] + if layer_count == 2: + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w:", format=tarfile.USTAR_FORMAT) as archive: + content = b"second layer verifies prefix chain and legacy parent binding" + info = tarfile.TarInfo("second.txt") + info.size = len(content) + archive.addfile(info, io.BytesIO(content)) + payload = stream.getvalue() + digest = "sha256:" + hashlib.sha256(payload).hexdigest() + name = "blobs/sha256/" + digest.split(":", 1)[1] + members[name] = payload + layer_rows.append((digest, name, payload)) images: dict[str, dict[str, str]] = {} docker_manifest: list[dict[str, object]] = [] index_descriptors: list[dict[str, object]] = [] @@ -169,7 +185,7 @@ def _docker29_oci_fixture( "architecture": "amd64", "os": "linux", "config": {"User": "10001:10001", "Labels": labels, "Cmd": [service]}, - "rootfs": {"type": "layers", "diff_ids": [layer_digest]}, + "rootfs": {"type": "layers", "diff_ids": [row[0] for row in layer_rows]}, } config_bytes = json.dumps(config, sort_keys=True, separators=(",", ":")).encode() image_id = "sha256:" + hashlib.sha256(config_bytes).hexdigest() @@ -180,13 +196,14 @@ def _docker29_oci_fixture( { "Config": config_name, "RepoTags": [reference], - "Layers": [layer_name], + "Layers": [row[1] for row in layer_rows], "LayerSources": { - layer_digest: { + digest: { "mediaType": "application/vnd.oci.image.layer.v1.tar", - "size": len(layer), - "digest": layer_digest, + "size": len(data), + "digest": digest, } + for digest, _name, data in layer_rows }, } ) @@ -201,9 +218,10 @@ def _docker29_oci_fixture( "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar", - "digest": layer_digest, - "size": len(layer), + "digest": digest, + "size": len(data), } + for digest, _name, data in layer_rows ], } image_manifest_bytes = json.dumps( @@ -466,6 +484,174 @@ def test_docker_oci_index_can_canonicalize_external_reference_aliases(tmp_path: assert normalized.is_file() +@pytest.mark.parametrize( + "case", + json.loads((ROOT / "tests/fixtures/moby_v1_golden.json").read_text(encoding="utf-8"))["cases"], +) +def test_independent_go_reference_matches_exact_legacy_wire_bytes(case): + expected = { + row["name"]: base64.b64decode(row["payloadBase64"], validate=True) for row in case["blobs"] + } + actual = producer._moby_v1_metadata( + case["config"], legacy_onbuild=case["dialect"] == "onbuild_null" + ) + assert actual == expected + + +def test_go_reference_fixture_is_bound_to_its_reproducible_generator(): + fixture = json.loads((ROOT / "tests/fixtures/moby_v1_golden.json").read_text(encoding="utf-8")) + assert fixture["schema"] == "itworx.moby-v1-wire-golden/v1" + assert fixture["goVersion"] == "go1.27.2" + assert ( + fixture["referenceMainSha256"] + == hashlib.sha256( + (ROOT / "tests/fixtures/moby_v1_reference/main.go").read_bytes() + ).hexdigest() + ) + assert fixture["sourceCommits"] == [ + "89c5e8fd66634b6128fc4c0e6f1236e2540e46e0", + "5834c866f83c0bae2d0be28f38b92f32cd07ca38", + ] + + +def _moby_v1_fixture(members, *, legacy_onbuild=True): + # Faithful classic Moby saveConfig/CreateID serialization for the fixed + # single-layer images above, independent of the production normalizer. + zero = { + "Hostname": "", + "Domainname": "", + "User": "", + "AttachStdin": False, + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "OpenStdin": False, + "StdinOnce": False, + "Env": None, + "Cmd": None, + "Image": "", + "Volumes": None, + "WorkingDir": "", + "Entrypoint": None, + "OnBuild": None, + "Labels": None, + } + if not legacy_onbuild: + del zero["OnBuild"] + result = dict(members) + added = [] + for row in json.loads(members["manifest.json"]): + config = json.loads(members[row["Config"]]) + runtime = {**zero, **config["config"]} + parent = "" + chain = "" + diffs = config["rootfs"]["diff_ids"] + for index, diff in enumerate(diffs): + chain = ( + diff + if not chain + else "sha256:" + hashlib.sha256((chain + " " + diff).encode()).hexdigest() + ) + if index == len(diffs) - 1: + body = { + "created": None, + "container_config": zero, + "config": runtime, + "architecture": "amd64", + "os": "linux", + } + else: + body = {"created": "1970-01-01T00:00:00Z", "container_config": zero} + preimage = {**body, "layer_id": chain} + if parent: + preimage["parent"] = "sha256:" + parent + preimage = dict(sorted(preimage.items())) + legacy_id = hashlib.sha256( + json.dumps(preimage, separators=(",", ":")).encode() + ).hexdigest() + saved = {"id": legacy_id} + if parent: + saved["parent"] = parent + saved.update(body) + saved["os"] = "linux" + payload = json.dumps(saved, separators=(",", ":")).encode() + name = "blobs/sha256/" + hashlib.sha256(payload).hexdigest() + result[name] = payload + added.append(name) + parent = legacy_id + return result, added + + +@pytest.mark.parametrize("legacy_onbuild", [True, False]) +@pytest.mark.parametrize("layer_count", [1, 2]) +def test_moby_v1_compatibility_metadata_preserves_exact_importer_bytes( + tmp_path, legacy_onbuild, layer_count +): + raw, members, images, metadata = _docker29_oci_fixture(tmp_path, layer_count=layer_count) + expected = tmp_path / "expected.tar" + producer._normalize_docker_archive(raw, expected, images, metadata=metadata) + extended, _added = _moby_v1_fixture(members, legacy_onbuild=legacy_onbuild) + raw_compat = tmp_path / "with-compat.tar" + _write_archive_fixture(raw_compat, extended) + actual = tmp_path / "actual.tar" + producer._normalize_docker_archive(raw_compat, actual, images, metadata=metadata) + assert actual.read_bytes() == expected.read_bytes() + + +def test_content_addressed_foreign_json_is_still_rejected(tmp_path): + _raw, members, images, metadata = _docker29_oci_fixture(tmp_path) + extended, _added = _moby_v1_fixture(members) + payload = b'{"id":"foreign","created":null,"os":"linux"}' + extended["blobs/sha256/" + hashlib.sha256(payload).hexdigest()] = payload + raw = tmp_path / "foreign.tar" + _write_archive_fixture(raw, extended) + output = tmp_path / "foreign-output.tar" + with pytest.raises(producer.BuildRejected): + producer._normalize_docker_archive(raw, output, images, metadata=metadata) + assert not output.exists() + + +@pytest.mark.parametrize( + "change", ["default", "unknown", "parent", "chain", "config", "order", "id"] +) +def test_self_consistent_foreign_v1_metadata_never_extends_archive_inventory(tmp_path, change): + _raw, members, images, metadata = _docker29_oci_fixture(tmp_path, layer_count=2) + extended, added = _moby_v1_fixture(members) + original_name = added[-1] + body = json.loads(extended.pop(original_name)) + first = json.loads(members["manifest.json"])[0] + diffs = json.loads(members[first["Config"]])["rootfs"]["diff_ids"] + chain = "sha256:" + hashlib.sha256((diffs[0] + " " + diffs[1]).encode()).hexdigest() + if change == "default": + body["container_config"]["Tty"] = True + elif change == "unknown": + body["unexpected"] = "fixture-sensitive-foreign-field" + elif change == "parent": + body["parent"] = "e" * 64 + elif change == "chain": + chain = "sha256:" + "e" * 64 + elif change == "config": + body["config"]["User"] = "root" + elif change == "order": + body["container_config"] = dict(sorted(body["container_config"].items())) + preimage = {key: value for key, value in body.items() if key not in {"id", "parent"}} + preimage.update(layer_id=chain, parent="sha256:" + body["parent"]) + body["id"] = hashlib.sha256( + json.dumps(dict(sorted(preimage.items())), separators=(",", ":")).encode() + ).hexdigest() + if change == "id": + body["id"] = "e" * 64 + payload = json.dumps(body, separators=(",", ":")).encode() + extended["blobs/sha256/" + hashlib.sha256(payload).hexdigest()] = payload + raw = tmp_path / "spoof.tar" + _write_archive_fixture(raw, extended) + output = tmp_path / "spoof-output.tar" + with pytest.raises(producer.BuildRejected) as error: + producer._normalize_docker_archive(raw, output, images, metadata=metadata) + assert "fixture-sensitive" not in str(error.value) + assert not output.exists() + + def test_docker29_oci_normalizer_rejects_unreferenced_blobs_and_config_mismatch( tmp_path: Path, ) -> None: