From 7903a9fce0f3e07af669851d840cc92914046ef9 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Tue, 27 Oct 2026 16:24:57 +0100 Subject: [PATCH] fix(addons): verify the platform child of an add-on index (#12943) Stage pinned the index digest but compared the fetched platform image's digest against it. The fetched image is the child, so every install of a multi-platform add-on failed. Compare the fetched image with the child the index lists for the release OS and architecture. A single manifest still must equal the pinned digest, and an index without the platform is refused. The signature and the receipt keep the index digest. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/services/addons/packages/install.go | 44 ++++++- core/services/addons/packages/install_test.go | 124 +++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/core/services/addons/packages/install.go b/core/services/addons/packages/install.go index f7408f461..d98130f54 100644 --- a/core/services/addons/packages/install.go +++ b/core/services/addons/packages/install.go @@ -17,8 +17,10 @@ import ( "strings" "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/mudler/LocalAI/core/services/addons/catalog" "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/pkg/credentials" "github.com/mudler/LocalAI/pkg/httpclient" "github.com/mudler/LocalAI/pkg/oci" "github.com/mudler/LocalAI/pkg/oci/cosignverify" @@ -141,8 +143,12 @@ func (i *Installer) Stage(ctx context.Context, r catalog.Release, progress func( if e != nil { return out, ErrPackage } + want, e := expectedImageDigest(pinned, r, transport) + if e != nil { + return out, ErrPackage + } digest, e := img.Digest() - if e != nil || digest.String() != r.Digest { + if e != nil || digest.String() != want { return out, ErrPackage } cfg, e := img.ConfigFile() @@ -408,3 +414,39 @@ func (t boundTransport) RoundTrip(req *http.Request) (*http.Response, error) { cur = next } } + +// expectedImageDigest returns the digest the fetched platform image must have. +// The pinned digest names what the signature covers. For a single manifest it +// is the image digest. For an image index it is the index digest, and the image +// to install is the child the index lists for the release platform. An index +// that does not list that platform is refused. +func expectedImageDigest(pinned string, r catalog.Release, rt http.RoundTripper) (string, error) { + ref, err := name.ParseReference(pinned) + if err != nil { + return "", err + } + desc, err := remote.Get(ref, remote.WithTransport(rt), remote.WithAuthFromKeychain(credentials.Keychain())) + if err != nil { + return "", err + } + if desc.Digest.String() != r.Digest { + return "", ErrPackage + } + if !desc.MediaType.IsIndex() { + return r.Digest, nil + } + index, err := desc.ImageIndex() + if err != nil { + return "", err + } + manifest, err := index.IndexManifest() + if err != nil { + return "", err + } + for _, child := range manifest.Manifests { + if child.Platform != nil && child.Platform.OS == r.OS && child.Platform.Architecture == r.Arch { + return child.Digest.String(), nil + } + } + return "", ErrPackage +} diff --git a/core/services/addons/packages/install_test.go b/core/services/addons/packages/install_test.go index 73f46be37..b7213d0cc 100644 --- a/core/services/addons/packages/install_test.go +++ b/core/services/addons/packages/install_test.go @@ -89,7 +89,7 @@ func fixture(data []byte, edits ...func(*catalog.Release, *catalog.Source)) (*In Expect(s.Save(context.Background(), contract.Actor{}, src)).To(Succeed()) _, e = s.Refresh(context.Background(), "test") Expect(e).NotTo(HaveOccurred()) - c := catalog.Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"} + c := catalog.Catalog{Sources: s, TargetOS: r.OS, TargetArch: r.Arch} resolved, e := c.Resolve(context.Background(), "test", "sample", "latest") Expect(e).NotTo(HaveOccurred()) root := GinkgoT().TempDir() @@ -101,7 +101,7 @@ func fixture(data []byte, edits ...func(*catalog.Release, *catalog.Source)) (*In return nil }) }) - return &Installer{Root: root, Catalog: c, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}}, resolved + return &Installer{Root: root, Catalog: c, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: r.OS, Arch: r.Arch}}, resolved } var _ = Describe("immutable packages", func() { @@ -762,3 +762,123 @@ var _ = Describe("a mutable development add-on", func() { Expect(e).To(HaveOccurred()) }) }) + +// An add-on is published as an image index with one child per platform. The +// digest in the catalog and the signature name the index; the installed image +// is the child the index lists for the host platform. +var _ = Describe("multi-platform add-on index", func() { + platforms := []v1.Platform{{OS: "linux", Architecture: "amd64"}, {OS: "linux", Architecture: "arm64"}, {OS: "darwin", Architecture: "amd64"}, {OS: "darwin", Architecture: "arm64"}} + // The package manifest names its own platform, so each child carries one. + child := func(data []byte, p v1.Platform) v1.Image { + layer, e := tarball.LayerFromReader(bytes.NewReader(data)) + Expect(e).NotTo(HaveOccurred()) + img, e := mutate.AppendLayers(empty.Image, layer) + Expect(e).NotTo(HaveOccurred()) + cfg, e := img.ConfigFile() + Expect(e).NotTo(HaveOccurred()) + cfg.OS, cfg.Architecture = p.OS, p.Architecture + img, e = mutate.ConfigFile(img, cfg) + Expect(e).NotTo(HaveOccurred()) + return img + } + index := func(imgs map[string]v1.Image, order []v1.Platform) v1.ImageIndex { + idx := mutate.IndexMediaType(empty.Index, types.OCIImageIndex) + for _, p := range order { + p := p + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{Add: imgs[p.OS+"/"+p.Architecture], Descriptor: v1.Descriptor{Platform: &p}}) + } + return idx + } + publish := func(idx v1.ImageIndex) (string, string) { + server := httptest.NewServer(registry.New()) + DeferCleanup(server.Close) + ref, e := name.ParseReference(strings.TrimPrefix(server.URL, "http://") + "/local-ai-addons:sample-development") + Expect(e).NotTo(HaveOccurred()) + Expect(remote.WriteIndex(ref, idx)).To(Succeed()) + d, e := idx.Digest() + Expect(e).NotTo(HaveOccurred()) + return "oci://" + ref.Name(), d.String() + } + manifest := func() []byte { + m, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json") + Expect(e).NotTo(HaveOccurred()) + return m + } + platformData := func(p v1.Platform, mark byte) []byte { + m := strings.NewReplacer(`"os":"linux"`, `"os":"`+p.OS+`"`, `"arch":"amd64"`, `"arch":"`+p.Architecture+`"`).Replace(string(manifest())) + return archive([]*tar.Header{{Name: "mark", Mode: 0644, Size: int64(mark)}}, []byte(m)) + } + for _, host := range platforms { + host := host + It("installs the "+host.OS+"/"+host.Architecture+" child of an index with four platforms", func() { + data := archive(nil, manifest()) + imgs := map[string]v1.Image{} + for n, p := range platforms { + imgs[p.OS+"/"+p.Architecture] = child(platformData(p, byte(n+1)), p) + } + artifact, digest := publish(index(imgs, platforms)) + i, r := fixture(data, func(r *catalog.Release, s *catalog.Source) { + r.Artifact, r.Digest, r.OS, r.Arch = artifact, digest, host.OS, host.Architecture + }) + staged, e := i.Stage(context.Background(), r, nil) + Expect(e).NotTo(HaveOccurred()) + receipt, e := i.Commit(context.Background(), staged) + Expect(e).NotTo(HaveOccurred()) + Expect(receipt.Digest).To(Equal(digest)) + }) + } + It("refuses an index that does not list the host platform", func() { + data := archive(nil, manifest()) + imgs := map[string]v1.Image{} + for n, p := range platforms[1:] { + imgs[p.OS+"/"+p.Architecture] = child(platformData(p, byte(n+1)), p) + } + artifact, digest := publish(index(imgs, platforms[1:])) + i, r := fixture(data, func(r *catalog.Release, s *catalog.Source) { r.Artifact, r.Digest = artifact, digest }) + _, e := i.Stage(context.Background(), r, nil) + Expect(e).To(MatchError(ErrPackage)) + }) + It("refuses a child whose digest differs from the one the index lists", func() { + data := archive(nil, manifest()) + listed := child(platformData(platforms[0], 1), platforms[0]) + served := child(platformData(platforms[0], 2), platforms[0]) + idx := index(map[string]v1.Image{"linux/amd64": listed}, platforms[:1]) + server := httptest.NewServer(registry.New()) + DeferCleanup(server.Close) + ref, e := name.ParseReference(strings.TrimPrefix(server.URL, "http://") + "/local-ai-addons:sample-development") + Expect(e).NotTo(HaveOccurred()) + Expect(remote.WriteIndex(ref, idx)).To(Succeed()) + listedDigest, e := listed.Digest() + Expect(e).NotTo(HaveOccurred()) + servedDigest, e := served.Digest() + Expect(e).NotTo(HaveOccurred()) + Expect(remote.Write(ref.Context().Digest(servedDigest.String()), served)).To(Succeed()) + // Serve the substitute child wherever the listed child is requested. + real := server.Config.Handler + swap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, rq *http.Request) { + rq.URL.Path = strings.ReplaceAll(rq.URL.Path, listedDigest.String(), servedDigest.String()) + real.ServeHTTP(w, rq) + })) + DeferCleanup(swap.Close) + d, e := idx.Digest() + Expect(e).NotTo(HaveOccurred()) + i, r := fixture(data, func(r *catalog.Release, s *catalog.Source) { + r.Artifact = "oci://" + strings.TrimPrefix(swap.URL, "http://") + "/local-ai-addons:sample-development" + r.Digest = d.String() + }) + _, e = i.Stage(context.Background(), r, nil) + Expect(e).To(MatchError(ErrPackage)) + }) + It("still installs a single manifest and refuses a different digest", func() { + data := archive(nil, manifest()) + artifact, digest, _ := localOCI(data) + i, r := fixture(data, func(r *catalog.Release, s *catalog.Source) { r.Artifact, r.Digest = artifact, digest }) + _, e := i.Stage(context.Background(), r, nil) + Expect(e).NotTo(HaveOccurred()) + i, r = fixture(data, func(r *catalog.Release, s *catalog.Source) { + r.Artifact, r.Digest = artifact, "sha256:"+strings.Repeat("0", 64) + }) + _, e = i.Stage(context.Background(), r, nil) + Expect(e).To(MatchError(ErrPackage)) + }) +})