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 <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-27 16:24:57 +01:00
1 parent 654068cf8a
commit 7903a9fce0
2 files changed
+165 -3

No files matched your search

+43 -1
View File
@@ -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
}
+122 -2
View File
@@ -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))
})
})