diff --git a/core/cli/backends.go b/core/cli/backends.go index 91449032d..70b0e5697 100644 --- a/core/cli/backends.go +++ b/core/cli/backends.go @@ -63,6 +63,7 @@ func (bl *BackendsList) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithBackendSystemPath(bl.BackendsSystemPath), system.WithBackendPath(bl.BackendsPath), + system.WithRequireBackendIntegrity(bl.RequireBackendIntegrity), ) if err != nil { return err @@ -107,6 +108,7 @@ func (bi *BackendsInstall) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithBackendSystemPath(bi.BackendsSystemPath), system.WithBackendPath(bi.BackendsPath), + system.WithRequireBackendIntegrity(bi.RequireBackendIntegrity), ) if err != nil { return err @@ -144,6 +146,7 @@ func (bu *BackendsUpgrade) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithBackendSystemPath(bu.BackendsSystemPath), system.WithBackendPath(bu.BackendsPath), + system.WithRequireBackendIntegrity(bu.RequireBackendIntegrity), ) if err != nil { return err diff --git a/core/cli/models.go b/core/cli/models.go index c6961c402..7b09f5ed5 100644 --- a/core/cli/models.go +++ b/core/cli/models.go @@ -82,6 +82,7 @@ func (mi *ModelsInstall) Run(ctx *cliContext.Context) error { systemState, err := system.GetSystemState( system.WithModelPath(mi.ModelsPath), system.WithBackendPath(mi.BackendsPath), + system.WithRequireBackendIntegrity(mi.RequireBackendIntegrity), ) if err != nil { return err diff --git a/core/cli/run.go b/core/cli/run.go index 9e663687c..3dc165ecc 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -284,6 +284,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error { system.WithBackendImagesBranchTag(r.BackendImagesBranchTag), system.WithBackendDevSuffix(r.BackendDevSuffix), system.WithPreferDevelopmentBackends(r.PreferDevelopmentBackends), + system.WithRequireBackendIntegrity(r.RequireBackendIntegrity), ) if err != nil { return err diff --git a/core/gallery/entry_url.go b/core/gallery/entry_url.go new file mode 100644 index 000000000..737d8116a --- /dev/null +++ b/core/gallery/entry_url.go @@ -0,0 +1,139 @@ +package gallery + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/downloader" + "github.com/mudler/LocalAI/pkg/oci" +) + +// isRelativeEntryURL reports whether an entry url names a document inside the +// gallery rather than a source of its own. +// +// The test is "no scheme at all" rather than a list of shapes to accept: every +// transport the downloader knows announces itself with a prefix, and the bare +// registry hosts it also accepts (quay.io, ghcr.io, docker.io) are covered by +// LooksLikeOCI. Anything left is a path, and a path can only mean a path in +// the gallery it was published in. +func isRelativeEntryURL(entryURL string) bool { + if entryURL == "" { + return false + } + if strings.Contains(entryURL, "://") { + return false + } + uri := downloader.URI(entryURL) + return !uri.LooksLikeURL() && !uri.LooksLikeOCI() +} + +// ociGalleryRoot returns the unpacked artifact directory a gallery's entries +// resolve against, or "" when the gallery is not an OCI one or nothing was +// unpacked for it. +// +// Mirrors are considered, and in the same order the fetch tries them: an OCI +// gallery whose primary was unreachable is served by a mirror, and the entries +// of the copy that actually landed on disk are the ones that can be installed. +func ociGalleryRoot(g config.Gallery, basePath string) string { + for _, candidate := range galleryCandidates(g) { + if !looksLikeOCIGallery(candidate) { + continue + } + dir := ociGalleryCacheDir(basePath, candidate) + if dir == "" { + continue + } + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir + } + } + return "" +} + +// resolveGalleryEntryURL turns an entry url that is relative to the gallery +// root into one the downloader can fetch, and leaves every other url alone. +// +// A gallery published as a self-contained tree, which is what an OCI gallery +// is, names its base configs by their place in that tree: url: base/virtual.yaml +// means the file next to the index, not a host somewhere. Without this the +// string reaches the HTTP client verbatim and the entry cannot be installed at +// all. +// +// The relative path may not climb out of the gallery root. It is published +// data, so an entry that resolved above the root would let a gallery read +// whatever it pointed at on the machine listing it. +func resolveGalleryEntryURL(entryURL string, g config.Gallery, basePath string) (string, error) { + if !isRelativeEntryURL(entryURL) { + return entryURL, nil + } + + if root := ociGalleryRoot(g, basePath); root != "" { + target, err := oci.ResolveInRoot(root, entryURL) + if err != nil { + return "", fmt.Errorf("entry url %q is not a path inside gallery %q: %w", entryURL, g.Name, err) + } + return downloader.LocalPrefix + target, nil + } + + if looksLikeOCIGallery(g.URL) { + // The index came from the artifact, so the artifact is on disk. If it + // is not, the entry cannot be read from anywhere and saying so beats + // resolving it against a directory that is not the gallery. + return "", fmt.Errorf("entry url %q needs the unpacked artifact of gallery %q, which is not in the cache", entryURL, g.Name) + } + + return resolveAgainstIndexURL(g.URL, entryURL) +} + +// galleryConfigReadRoot returns the directory a gallery config read is +// confined to. +// +// The downloader keeps a file:// read inside the base path it is given, and +// that is normally the models directory. An entry of an OCI gallery lives in +// the unpacked artifact instead, which is deliberately a sibling of the models +// directory so unpacked YAML is never mistaken for an installed model config. +// Reading it therefore needs the cache root as its own trusted root; the +// downloader still resolves symlinks against it, so the confinement the models +// directory gave is not lost, only moved to the directory the file is actually +// in. +func galleryConfigReadRoot(url, basePath string) string { + root := ociGalleryCacheRoot(basePath) + if root == "" || !strings.HasPrefix(url, downloader.LocalPrefix) { + return basePath + } + target := filepath.Clean(strings.TrimPrefix(url, downloader.LocalPrefix)) + if target == root || strings.HasPrefix(target, root+string(os.PathSeparator)) { + return root + } + return basePath +} + +// resolveAgainstIndexURL resolves a relative entry url against the directory +// of the index URL. +// +// The URL is handled as text, the way findGalleryURLFromReferenceURL already +// handles a .ref file, because these are not all RFC 3986 URLs: github: and +// hf:// carry their own syntax. The one piece of that syntax that must survive +// is the @branch a github: URL ends with, which belongs to the whole reference +// and not to the file name. +func resolveAgainstIndexURL(indexURL, relative string) (string, error) { + clean, err := oci.SafeRelativePath(relative) + if err != nil { + return "", fmt.Errorf("entry url %q is not a path inside the gallery: %w", relative, err) + } + + cut := strings.LastIndex(indexURL, "/") + if cut < 0 { + return "", fmt.Errorf("gallery url %q names no directory to resolve %q against", indexURL, relative) + } + + base, suffix := indexURL, "" + if at := strings.Index(indexURL[cut:], "@"); at >= 0 { + suffix = indexURL[cut+at:] + base = indexURL[:cut+at] + } + return base[:cut+1] + clean + suffix, nil +} diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go index 04752b3d1..f094cc4f3 100644 --- a/core/gallery/gallery.go +++ b/core/gallery/gallery.go @@ -46,7 +46,7 @@ func GetGalleryConfigFromURL[T any](url string, basePath string) (T, error) { return config, err } uri := downloader.URI(url) - err := uri.ReadWithCallback(basePath, func(url string, d []byte) error { + err := uri.ReadWithCallback(galleryConfigReadRoot(url, basePath), func(url string, d []byte) error { return yaml.Unmarshal(d, &config) }) if err != nil { @@ -63,7 +63,7 @@ func GetGalleryConfigFromURLWithContext[T any](ctx context.Context, url string, return config, err } uri := downloader.URI(url) - err := uri.ReadWithAuthorizationAndCallback(ctx, basePath, "", func(url string, d []byte) error { + err := uri.ReadWithAuthorizationAndCallback(ctx, galleryConfigReadRoot(url, basePath), "", func(url string, d []byte) error { return yaml.Unmarshal(d, &config) }) if err != nil { @@ -278,7 +278,7 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst // Get models from galleries for _, gallery := range galleries { - galleryModels, err := getGalleryElements(gallery, systemState.Model.ModelsPath, func(model *GalleryModel) bool { + galleryModels, err := getGalleryElements(gallery, systemState.Model.ModelsPath, systemState.RequireBackendIntegrity, func(model *GalleryModel) bool { if _, err := os.Stat(filepath.Join(systemState.Model.ModelsPath, fmt.Sprintf("%s.yaml", model.GetName()))); err == nil { return true } @@ -291,14 +291,32 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst // Resolve model URLs locally (for local galleries) and collect unique // URLs that need fetching for backend resolution. uniqueURLs := map[string]struct{}{} + usable := make([]*GalleryModel, 0, len(galleryModels)) for _, m := range galleryModels { if m.URL != "" { m.URL = resolveModelURLLocally(m.URL, gallery.URL) + // The gallery carried on the entry is the one the index was + // really read from, with a .ref indirection already followed, + // so it is the root an entry path is relative to. + resolved, err := resolveGalleryEntryURL(m.URL, m.GetGallery(), systemState.Model.ModelsPath) + if err != nil { + // One unusable entry must not cost the user the rest of + // the gallery, so it is dropped and named rather than + // failing the listing. It is left out entirely because an + // entry whose url does not resolve cannot be installed, + // and offering it would only fail later and further away. + xlog.Error("dropping a gallery entry whose url does not resolve", + "gallery", gallery.Name, "model", m.Name, "url", m.URL, "error", err) + continue + } + m.URL = resolved } + usable = append(usable, m) if m.Backend == "" && m.URL != "" { uniqueURLs[m.URL] = struct{}{} } } + galleryModels = usable // Pre-warm cache with parallel fetches to avoid sequential HTTP // requests on cold start (~50 unique gallery config files). @@ -543,7 +561,7 @@ func availableBackendsWithFilter(galleries []config.Gallery, systemState *system // Get backends from galleries for _, gallery := range galleries { - galleryBackends, err := getGalleryElements(gallery, systemState.Backend.BackendsPath, func(backend *GalleryBackend) bool { + galleryBackends, err := getGalleryElements(gallery, systemState.Backend.BackendsPath, systemState.RequireBackendIntegrity, func(backend *GalleryBackend) bool { return systemBackends.Exists(backend.GetName()) }) if err != nil { @@ -591,7 +609,7 @@ func (entry galleryCacheEntry) hasExpired() bool { var galleryCache = xsync.NewSyncedMap[string, galleryCacheEntry]() -func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath string, isInstalledCallback func(T) bool) ([]T, error) { +func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath string, requireIntegrity bool, isInstalledCallback func(T) bool) ([]T, error) { var models []T = []T{} if strings.HasSuffix(gallery.URL, ".ref") { @@ -620,7 +638,7 @@ func getGalleryElements[T GalleryElement](gallery config.Gallery, basePath strin // The cache key stays the gallery's identity rather than the URL that // answered: a mirror serves the same index, so a mirror-served fetch // must populate the entry the primary would have filled. - body, servedBy, err := fetchGalleryIndex(context.Background(), gallery, basePath) + body, servedBy, err := fetchGalleryIndex(context.Background(), gallery, basePath, requireIntegrity) if err != nil { return models, fmt.Errorf("failed to read gallery elements: %w", err) } diff --git a/core/gallery/gallery_entry_url_test.go b/core/gallery/gallery_entry_url_test.go new file mode 100644 index 000000000..00cfa880d --- /dev/null +++ b/core/gallery/gallery_entry_url_test.go @@ -0,0 +1,110 @@ +package gallery + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// noProgress is the download callback the install specs do not care about. +func noProgress(string, string, string, float64) {} + +// galleryModelNames lists what a listing produced, so a spec can say which +// entries survived without depending on their order. +func galleryModelNames(models GalleryElements[*GalleryModel]) []string { + names := []string{} + for _, m := range models { + names = append(names, m.Name) + } + return names +} + +var _ = Describe("gallery entry URLs relative to the gallery root", func() { + BeforeEach(resetGalleryFailures) + + It("installs an entry whose url is relative to an oci:// gallery root", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/relative", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: "- name: relative-entry\n url: base/virtual.yaml\n"}, + {title: "base/virtual.yaml", body: "name: virtual\nconfig_file: |\n backend: llama\n"}, + }) + + base := tempModelsDir() + systemState, err := system.GetSystemState(system.WithModelPath(base)) + Expect(err).ToNot(HaveOccurred()) + galleries := []config.Gallery{{Name: "relative", URL: url}} + + models, err := AvailableGalleryModels(galleries, systemState) + Expect(err).ToNot(HaveOccurred()) + Expect(galleryModelNames(models)).To(ConsistOf("relative-entry")) + + // The entry is only really resolved if it installs: the base config + // has to be read back out of the unpacked artifact. + err = InstallModelFromGallery(context.Background(), galleries, nil, systemState, nil, + "relative@relative-entry", GalleryModel{}, noProgress, false, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(filepath.Join(base, "relative-entry.yaml")).To(BeAnExistingFile()) + }) + + It("resolves a relative entry url against the directory of an http gallery index", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gallery/index.yaml" { + _, _ = w.Write([]byte("- name: relative-entry\n url: base/virtual.yaml\n" + + "- name: absolute-entry\n url: https://example.invalid/some/other.yaml\n")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + DeferCleanup(srv.Close) + + base := tempModelsDir() + systemState, err := system.GetSystemState(system.WithModelPath(base)) + Expect(err).ToNot(HaveOccurred()) + + models, err := AvailableGalleryModels([]config.Gallery{{ + Name: "http-relative", + URL: srv.URL + "/gallery/index.yaml", + }}, systemState) + Expect(err).ToNot(HaveOccurred()) + Expect(galleryModelNames(models)).To(ConsistOf("relative-entry", "absolute-entry")) + + byName := map[string]string{} + for _, m := range models { + byName[m.Name] = m.URL + } + Expect(byName["relative-entry"]).To(Equal(srv.URL + "/gallery/base/virtual.yaml")) + // An entry that names its own source keeps it, whatever the gallery + // root is. + Expect(byName["absolute-entry"]).To(Equal("https://example.invalid/some/other.yaml")) + }) + + It("refuses an entry url that climbs out of the gallery root", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/escaping", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: "- name: escaping-entry\n url: ../../../etc/passwd\n" + + "- name: honest-entry\n url: base/virtual.yaml\n"}, + {title: "base/virtual.yaml", body: "name: virtual\nconfig_file: |\n backend: llama\n"}, + }) + + base := tempModelsDir() + systemState, err := system.GetSystemState(system.WithModelPath(base)) + Expect(err).ToNot(HaveOccurred()) + galleries := []config.Gallery{{Name: "escaping", URL: url}} + + models, err := AvailableGalleryModels(galleries, systemState) + Expect(err).ToNot(HaveOccurred()) + // The escaping entry is dropped, and a single bad entry does not cost + // the user the rest of the gallery. + Expect(galleryModelNames(models)).To(ConsistOf("honest-entry")) + + err = InstallModelFromGallery(context.Background(), galleries, nil, systemState, nil, + "escaping@escaping-entry", GalleryModel{}, noProgress, false, false, false) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/gallery/gallery_mirrors.go b/core/gallery/gallery_mirrors.go index a4b9384ed..0d5db2370 100644 --- a/core/gallery/gallery_mirrors.go +++ b/core/gallery/gallery_mirrors.go @@ -197,7 +197,7 @@ func persistGalleryIndex(basePath, url string, body []byte) { // If no candidate answers, the last known good copy on disk is served and its // path is returned as the source. Nothing else in the chain helps a machine // that has no network at all. -func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string) ([]byte, string, error) { +func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string, requireIntegrity bool) ([]byte, string, error) { candidates := galleryCandidates(g) if len(candidates) == 0 { return nil, "", fmt.Errorf("gallery %q has no URL", g.Name) @@ -218,12 +218,23 @@ func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string) ( attemptCtx, cancel := context.WithTimeout(ctx, galleryFetchTimeout) var body []byte - err := downloader.URI(candidate).ReadWithAuthorizationAndCallback( - attemptCtx, basePath, "", - func(_ string, d []byte) error { - body = d - return nil - }) + var err error + // An oci:// candidate is an artifact in a registry, not a document at + // a URL: it is pulled, optionally signature-checked and unpacked. The + // rest of this loop does not care which it was, so mirrors, cooldown, + // the per-candidate timeout and the last known good copy all work the + // same for both schemes, and a gallery can even mirror an OCI primary + // with an HTTP fallback. + if looksLikeOCIGallery(candidate) { + body, err = fetchOCIGalleryIndex(attemptCtx, g, candidate, basePath, requireIntegrity) + } else { + err = downloader.URI(candidate).ReadWithAuthorizationAndCallback( + attemptCtx, basePath, "", + func(_ string, d []byte) error { + body = d + return nil + }) + } cancel() if err == nil { diff --git a/core/gallery/gallery_mirrors_test.go b/core/gallery/gallery_mirrors_test.go index b631b7aeb..7903c63a9 100644 --- a/core/gallery/gallery_mirrors_test.go +++ b/core/gallery/gallery_mirrors_test.go @@ -126,7 +126,7 @@ var _ = Describe("fetchGalleryIndex", func() { body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(mirror.URL)) Expect(string(body)).To(Equal("- name: from-mirror\n")) @@ -139,7 +139,7 @@ var _ = Describe("fetchGalleryIndex", func() { body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(primary.URL)) Expect(string(body)).To(Equal("- name: from-primary\n")) @@ -152,13 +152,13 @@ var _ = Describe("fetchGalleryIndex", func() { _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: down.URL, Mirrors: []string{down.URL + "/other"}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when nothing can serve the index") Expect(hits.Load()).To(BeEquivalentTo(2), "want both candidates tried") }) It("errors for a gallery with neither a URL nor mirrors", func() { - _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{Name: "empty"}, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), config.Gallery{Name: "empty"}, tempModelsDir(), false) Expect(err).To(HaveOccurred()) }) @@ -172,7 +172,7 @@ var _ = Describe("fetchGalleryIndex", func() { body, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(mirror.URL), "a 404 body was taken for an index") Expect(string(body)).To(Equal("- name: from-mirror\n")) @@ -186,7 +186,7 @@ var _ = Describe("fetchGalleryIndex", func() { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := fetchGalleryIndex(ctx, config.Gallery{URL: srv.URL}, tempModelsDir()) + _, _, err := fetchGalleryIndex(ctx, config.Gallery{URL: srv.URL}, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when the caller's context is already cancelled") Expect(hits.Load()).To(BeZero(), "server dialled despite a cancelled context") // The source did nothing wrong. Blaming it would blackhole a healthy @@ -229,7 +229,7 @@ var _ = Describe("fetchGalleryIndex", func() { done := make(chan outcome, 1) go func() { defer GinkgoRecover() - _, served, err := fetchGalleryIndex(context.Background(), g, basePath) + _, served, err := fetchGalleryIndex(context.Background(), g, basePath, false) done <- outcome{served, err} }() @@ -261,7 +261,7 @@ var _ = Describe("fetchGalleryIndex", func() { expireGalleryFailure(down.URL+"/a", time.Now()) expireGalleryFailure(down.URL+"/b", time.Now()) - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when nothing can serve the index") Expect(err.Error()).To(And( ContainSubstring("3 configured"), @@ -282,7 +282,7 @@ var _ = Describe("the gallery source cooldown", func() { g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}} for i := 0; i < 3; i++ { - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred(), "fetch %d", i) } Expect(hits.Load()).To(BeEquivalentTo(1), "primary re-dialled — cooldown is not holding") @@ -293,12 +293,12 @@ var _ = Describe("the gallery source cooldown", func() { mirror, _ := countingServer(http.StatusOK, "- name: from-mirror\n") g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}} - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) // Age the recorded failure past the cooldown rather than sleeping. expireGalleryFailure(primary.URL, time.Now().Add(-2*galleryFailureCooldown)) - _, _, err = fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err = fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(hits.Load()).To(BeEquivalentTo(2), "primary was not re-dialled — cooldown never expired") }) @@ -317,7 +317,7 @@ var _ = Describe("the gallery source cooldown", func() { _, served, err := fetchGalleryIndex(context.Background(), config.Gallery{ URL: primary.URL, Mirrors: []string{mirror.URL}, - }, tempModelsDir()) + }, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred()) Expect(served).To(Equal(mirror.URL)) Expect(primaryHits.Load()).To(BeEquivalentTo(1), "cooldown should have been ignored, not obeyed") @@ -332,7 +332,7 @@ var _ = Describe("the gallery source cooldown", func() { expireGalleryFailure(srv.URL, time.Now()) g := config.Gallery{URL: srv.URL} for i := 0; i < 2; i++ { - _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir()) + _, _, err := fetchGalleryIndex(context.Background(), g, tempModelsDir(), false) Expect(err).ToNot(HaveOccurred(), "fetch %d", i) } Expect(hits.Load()).To(BeEquivalentTo(2), "a successful fetch must clear the cooldown") @@ -352,7 +352,7 @@ var _ = Describe("getGalleryElements", func() { g := config.Gallery{Name: "mirror-fallback-spec", URL: primary.URL, Mirrors: []string{mirror.URL}} DeferCleanup(func() { galleryCache.Delete(g.Name + "-" + g.URL) }) - models, err := getGalleryElements(g, tempModelsDir(), func(*GalleryModel) bool { return false }) + models, err := getGalleryElements(g, tempModelsDir(), false, func(*GalleryModel) bool { return false }) Expect(err).ToNot(HaveOccurred()) Expect(models).To(HaveLen(1)) Expect(models[0].Name).To(Equal("mirror-model")) @@ -433,7 +433,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) body, err := os.ReadFile(galleryCachePath(base, srv.URL)) @@ -448,13 +448,13 @@ var _ = Describe("the last known good gallery index", func() { })) base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) srv.Close() // now nothing is reachable resetGalleryFailures() - body, served, err := fetchGalleryIndex(context.Background(), g, base) + body, served, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred(), "want the cached copy") Expect(string(body)).To(Equal("- name: cached\n")) Expect(served).To(Equal(galleryCachePath(base, srv.URL))) @@ -466,7 +466,7 @@ var _ = Describe("the last known good gallery index", func() { srv.Close() _, _, err := fetchGalleryIndex(context.Background(), - config.Gallery{URL: url, Name: "localai"}, tempModelsDir()) + config.Gallery{URL: url, Name: "localai"}, tempModelsDir(), false) Expect(err).To(HaveOccurred(), "want an error when there is neither a source nor a cached copy") }) @@ -481,7 +481,7 @@ var _ = Describe("the last known good gallery index", func() { Expect(os.WriteFile(filepath.Join(base, "..", "cache"), []byte("not a directory"), 0o600)).To(Succeed()) body, served, err := fetchGalleryIndex(context.Background(), - config.Gallery{URL: srv.URL, Name: "localai"}, base) + config.Gallery{URL: srv.URL, Name: "localai"}, base, false) Expect(err).ToNot(HaveOccurred(), "a cache write failure failed the whole fetch") Expect(served).To(Equal(srv.URL)) Expect(string(body)).To(Equal("- name: live\n")) @@ -495,7 +495,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: primary.URL, Mirrors: []string{mirror.URL}, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) body, err := os.ReadFile(galleryCachePath(base, g.URL)) @@ -519,11 +519,11 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) served = "- name: new\n" - body, from, err := fetchGalleryIndex(context.Background(), g, base) + body, from, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) Expect(string(body)).To(Equal("- name: new\n"), "want the live index from the source") Expect(from).To(Equal(srv.URL)) @@ -545,7 +545,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) cacheDir := filepath.Dir(galleryCachePath(base, g.URL)) @@ -555,7 +555,7 @@ var _ = Describe("the last known good gallery index", func() { srv.Close() resetGalleryFailures() - _, _, err = fetchGalleryIndex(context.Background(), g, base) + _, _, err = fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred(), "fallback") body, err := os.ReadFile(galleryCachePath(base, g.URL)) @@ -580,12 +580,12 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) // Now the same URL answers 200 with an interception page. served = "Sign in to the network\nPlease authenticate\n" - body, from, err := fetchGalleryIndex(context.Background(), g, base) + body, from, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) // The live body is still handed back — rejecting it here would hide the // failure from the caller that actually parses it. @@ -607,18 +607,18 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) // A proxy starts answering 200 with something that is not YAML at all. served = "\t\n\t 502 Bad Gateway\n\n" - _, _, err = fetchGalleryIndex(context.Background(), g, base) + _, _, err = fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) srv.Close() // and now the machine is offline resetGalleryFailures() - body, from, err := fetchGalleryIndex(context.Background(), g, base) + body, from, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred(), "offline fallback") Expect(from).To(Equal(galleryCachePath(base, g.URL)), "want the cached copy") @@ -644,11 +644,11 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) served = empty - _, _, err = fetchGalleryIndex(context.Background(), g, base) + _, _, err = fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) onDisk, err := os.ReadFile(galleryCachePath(base, g.URL)) @@ -668,7 +668,7 @@ var _ = Describe("the last known good gallery index", func() { base := tempModelsDir() g := config.Gallery{URL: srv.URL, Name: "localai"} - _, _, err := fetchGalleryIndex(context.Background(), g, base) + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) Expect(err).ToNot(HaveOccurred()) _, err = os.Stat(galleryCachePath(base, g.URL)) diff --git a/core/gallery/gallery_oci.go b/core/gallery/gallery_oci.go new file mode 100644 index 000000000..27915dc6f --- /dev/null +++ b/core/gallery/gallery_oci.go @@ -0,0 +1,209 @@ +package gallery + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/downloader" + "github.com/mudler/LocalAI/pkg/oci" + "github.com/mudler/xlog" +) + +const ( + // galleryArtifactType is what a published gallery declares as its + // artifactType. The puller refuses anything else, so a plain container + // image cannot be unpacked and read as a gallery. + galleryArtifactType = "application/vnd.localai.gallery.v1" + + // galleryIndexFile is the layer title the index is published under. The + // rest of the tree is kept next to it because entry URLs resolve against + // the gallery root. + galleryIndexFile = "index.yaml" + + // A gallery is a handful of small YAML files. Caps far below the puller's + // defaults keep a hostile or mistaken publisher from filling the disk of + // a machine that only asked to list some models. + maxGalleryArtifactLayers = 512 + maxGalleryArtifactBytes = int64(64 << 20) +) + +// ociGalleryCacheTTL is how long an unpacked gallery artifact is served +// without asking the registry again. +// +// A gallery URL usually points at a moving tag, so an unpacked copy that never +// expired would pin a machine to whatever the publisher shipped the first time +// it looked. An hour matches the in-memory gallery cache in gallery.go, so a +// refresh that reaches this layer is one the listing cache already decided to +// make. +// +// A var so specs can drive expiry without sleeping. +var ociGalleryCacheTTL = time.Hour + +// verifyGalleryArtifact checks the publisher signature over a digest-pinned +// artifact reference. +// +// A var so tests can drive the policy path without reaching the public +// Sigstore TUF mirror, which is a network dependency a unit test must not +// have. +var verifyGalleryArtifact = func(ctx context.Context, policy *config.GalleryVerification, digestRef string) error { + verifier, err := newGalleryVerifier(policy) + if err != nil { + return err + } + return verifier.VerifyImage(ctx, digestRef) +} + +// looksLikeOCIGallery reports whether a gallery candidate is an OCI artifact +// reference rather than something the HTTP downloader handles. +// +// Only the explicit oci:// scheme counts. downloader.URI.LooksLikeOCI also +// treats bare quay.io/ghcr.io/docker.io prefixes as OCI, which is right for a +// backend URI but wrong here: a gallery index served over HTTPS from one of +// those hosts is a perfectly ordinary URL, and it is written with a scheme. +func looksLikeOCIGallery(candidate string) bool { + return strings.HasPrefix(candidate, downloader.OCIPrefix) +} + +// ociGalleryCacheDir is where an unpacked gallery artifact lives. +// +// It follows galleryCachePath's convention: a sibling of the models directory +// so the unpacked YAML is never mistaken for an installed model config, named +// by a digest of the gallery URL so two galleries cannot collide, and empty +// for a non-absolute models directory because only an absolute one names a +// location we can reason about. +func ociGalleryCacheDir(basePath, url string) string { + root := ociGalleryCacheRoot(basePath) + if root == "" { + return "" + } + sum := sha256.Sum256([]byte(url)) + return filepath.Join(root, hex.EncodeToString(sum[:])) +} + +// ociGalleryCacheRoot is the directory every unpacked gallery artifact lives +// under, or "" when the models directory does not name one we can reason +// about. +// +// It is named on its own because it is also the trusted root a read of an +// unpacked entry is confined to: the cache is deliberately a sibling of the +// models directory, so the models directory cannot be that root. +func ociGalleryCacheRoot(basePath string) string { + if !filepath.IsAbs(basePath) { + return "" + } + return filepath.Join(basePath, "..", "cache", "gallery", "oci") +} + +// readCachedOCIGallery returns the cached index, if the cache holds one that +// is fresh and usable. +// +// The directory only exists because a completed pull was renamed into place, +// so its presence already means the content was verified before it landed. +// The index is still probed: a directory truncated by a full disk or edited by +// hand must not be served as if it were the gallery. +func readCachedOCIGallery(cacheDir string) ([]byte, bool) { + info, err := os.Stat(cacheDir) + if err != nil || !info.IsDir() { + return nil, false + } + if time.Since(info.ModTime()) > ociGalleryCacheTTL { + return nil, false + } + // #nosec G304 -- cacheDir is ociGalleryCacheDir's own construction, a hex + // digest under a fixed directory, and the file name is a constant. + body, err := os.ReadFile(filepath.Join(cacheDir, galleryIndexFile)) + if err != nil || !isUsableGalleryIndex(body) { + return nil, false + } + return body, true +} + +// fetchOCIGalleryIndex pulls a gallery artifact from a registry and returns +// its index. +// +// The pull lands in a staging directory and is renamed into place only once +// the whole tree is on disk and the index reads back as an index. The puller +// writes its layers with O_EXCL, so a directory holding partial files from an +// interrupted attempt would fail every later pull; and a partial tree that a +// later fetch served would hand the user a truncated gallery with no sign that +// anything went wrong. +func fetchOCIGalleryIndex(ctx context.Context, g config.Gallery, candidate, basePath string, requireIntegrity bool) ([]byte, error) { + cacheDir := ociGalleryCacheDir(basePath, candidate) + if cacheDir == "" { + return nil, fmt.Errorf("gallery %q needs an absolute models directory to cache %q", g.Name, candidate) + } + if body, ok := readCachedOCIGallery(cacheDir); ok { + return body, nil + } + + pullRef := strings.TrimPrefix(candidate, downloader.OCIPrefix) + + if g.Verification != nil { + // Resolve first, verify the digest, then pull that same digest. + // Nothing has been fetched at this point beyond the manifest, so a + // policy failure leaves no content anywhere. + digestRef, err := oci.ResolveArtifactDigestRef(ctx, pullRef) + if err != nil { + return nil, err + } + if err := verifyGalleryArtifact(ctx, g.Verification, digestRef); err != nil { + return nil, fmt.Errorf("gallery %q failed signature verification: %w", g.Name, err) + } + pullRef = digestRef + } else if requireIntegrity { + return nil, fmt.Errorf("strict integrity: gallery %q has no verification policy for %q (set verification: in the gallery configuration or disable --require-backend-integrity)", g.Name, candidate) + } else { + xlog.Warn("fetching an OCI gallery without signature verification", + "gallery", g.Name, "url", candidate) + } + + if err := os.MkdirAll(filepath.Dir(cacheDir), 0o750); err != nil { + return nil, fmt.Errorf("could not create the gallery cache directory: %w", err) + } + staging, err := os.MkdirTemp(filepath.Dir(cacheDir), ".staging-*") + if err != nil { + return nil, fmt.Errorf("could not stage the gallery artifact: %w", err) + } + // Removed on every path but the successful rename, which moves the + // directory out from under this call. + defer func() { + if rmErr := os.RemoveAll(staging); rmErr != nil && !os.IsNotExist(rmErr) { + xlog.Debug("could not clean up a gallery staging directory", "path", staging, "error", rmErr) + } + }() + + if _, err := oci.PullArtifact(ctx, pullRef, staging, + oci.WithArtifactType(galleryArtifactType), + oci.WithMaxArtifactLayers(maxGalleryArtifactLayers), + oci.WithMaxArtifactBytes(maxGalleryArtifactBytes)); err != nil { + return nil, err + } + + // #nosec G304 -- staging is this function's own temporary directory and + // the file name is a constant. + body, err := os.ReadFile(filepath.Join(staging, galleryIndexFile)) + if err != nil { + return nil, fmt.Errorf("gallery artifact %q carries no %s: %w", candidate, galleryIndexFile, err) + } + if !isUsableGalleryIndex(body) { + return nil, fmt.Errorf("gallery artifact %q has an %s that is not a gallery index", candidate, galleryIndexFile) + } + + // The bytes are already in hand, so a rename that loses a race with + // another fetch, or fails on a read-only cache, costs nothing but the next + // fetch pulling again. The caller still gets this gallery. + if err := os.RemoveAll(cacheDir); err != nil { + xlog.Debug("could not clear a stale gallery cache entry", "path", cacheDir, "error", err) + } else if err := os.Rename(staging, cacheDir); err != nil { + xlog.Debug("could not install the gallery cache entry", "path", cacheDir, "error", err) + } + + return body, nil +} diff --git a/core/gallery/gallery_oci_test.go b/core/gallery/gallery_oci_test.go new file mode 100644 index 000000000..4071d80ba --- /dev/null +++ b/core/gallery/gallery_oci_test.go @@ -0,0 +1,287 @@ +package gallery + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + + "github.com/google/go-containerregistry/pkg/registry" + "github.com/mudler/LocalAI/core/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry/remote" +) + +// errNoGallerySignature stands in for what the real verifier reports when an +// artifact has no signature attached. +var errNoGallerySignature = errors.New("no signature found for the gallery artifact") + +// stubGalleryVerifier replaces the signature check for the duration of a spec +// and restores it afterwards. +func stubGalleryVerifier(f func(context.Context, *config.GalleryVerification, string) error) { + GinkgoHelper() + original := verifyGalleryArtifact + verifyGalleryArtifact = f + DeferCleanup(func() { verifyGalleryArtifact = original }) +} + +// ociGalleryFile is one layer of a test gallery artifact: the title the puller +// lays the content out by, and the content itself. +type ociGalleryFile struct { + title string + body string +} + +// ociRegistry starts an in-process registry and returns it together with the +// counters the specs assert on: every request, and specifically blob reads. +// Blob reads are what "nothing was unpacked" means from the registry's side, +// so a spec can prove that a refused gallery never got as far as content. +func ociRegistry() (*httptest.Server, *atomic.Int64, *atomic.Int64) { + GinkgoHelper() + var requests, blobs atomic.Int64 + upstream := registry.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if strings.Contains(r.URL.Path, "/blobs/") { + blobs.Add(1) + } + upstream.ServeHTTP(w, r) + })) + DeferCleanup(srv.Close) + return srv, &requests, &blobs +} + +// pushGalleryArtifact publishes a gallery artifact to the in-process registry +// and returns the oci:// URL a gallery entry would carry. The publish goes +// through the same counting handler the fetch does, so specs reset the +// counters afterwards with resetCounters. +func pushGalleryArtifact(serverURL, repoPath, artifactType string, files []ociGalleryFile) string { + GinkgoHelper() + host := strings.TrimPrefix(serverURL, "http://") + ctx := context.Background() + + store := memory.New() + layers := []ocispec.Descriptor{} + for _, f := range files { + body := []byte(f.body) + desc := content.NewDescriptorFromBytes("application/yaml", body) + desc.Annotations = map[string]string{ocispec.AnnotationTitle: f.title} + Expect(store.Push(ctx, desc, bytes.NewReader(body))).To(Succeed()) + layers = append(layers, desc) + } + + manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, artifactType, oras.PackManifestOptions{ + Layers: layers, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(store.Tag(ctx, manifestDesc, "latest")).To(Succeed()) + + repo, err := remote.NewRepository(host + "/" + repoPath) + Expect(err).ToNot(HaveOccurred()) + repo.PlainHTTP = true + _, err = oras.Copy(ctx, store, "latest", repo, "latest", oras.DefaultCopyOptions) + Expect(err).ToNot(HaveOccurred()) + + return "oci://" + host + "/" + repoPath + ":latest" +} + +// resetCounters zeroes the request counters, so a spec measures the fetch and +// not the publish that set the scene for it. +func resetCounters(counters ...*atomic.Int64) { + for _, c := range counters { + c.Store(0) + } +} + +// ociCacheEntries lists what the OCI gallery cache root holds, so a spec can +// assert that a refused or failed pull left nothing behind at all, staging +// directories included. +func ociCacheEntries(basePath string) []string { + GinkgoHelper() + root := filepath.Join(basePath, "..", "cache", "gallery", "oci") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + Expect(err).ToNot(HaveOccurred()) + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +var _ = Describe("oci:// galleries", func() { + const index = "- name: premium-model\n" + + BeforeEach(resetGalleryFailures) + + It("reads the index from an artifact in a registry", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/premium", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + {title: "base/virtual.yaml", body: "- name: virtual\n"}, + }) + + base := tempModelsDir() + body, served, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "premium"}, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + Expect(served).To(Equal(url)) + + // The whole tree is unpacked, not just the index: entry URLs resolve + // against it. + Expect(filepath.Join(ociGalleryCacheDir(base, url), "base", "virtual.yaml")).To(BeAnExistingFile()) + }) + + It("serves a second fetch from the cache instead of pulling again", func() { + srv, requests, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/cached", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + + base := tempModelsDir() + g := config.Gallery{URL: url, Name: "cached"} + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).ToNot(HaveOccurred()) + + resetCounters(requests) + body, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + Expect(requests.Load()).To(BeZero(), "the registry was contacted although the artifact was already cached") + }) + + It("refuses an artifact that is not a gallery", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/wrongtype", "application/vnd.oci.image.config.v1+json", []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + + base := tempModelsDir() + _, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "wrongtype"}, base, false) + Expect(err).To(HaveOccurred()) + Expect(ociCacheEntries(base)).To(BeEmpty()) + }) + + Context("with a verification policy", func() { + policy := &config.GalleryVerification{ + Issuer: "https://token.actions.githubusercontent.com", + IdentityRegex: "^https://github.com/localai/premium/.*$", + } + + It("refuses a gallery whose artifact carries no signature, and caches nothing", func() { + srv, _, blobs := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/unsigned", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + resetCounters(blobs) + + // The real verifier reaches the public Sigstore TUF mirror, which + // a test must not depend on. The seam keeps the spec about what + // this package decides: an artifact that does not verify is never + // unpacked. + stubGalleryVerifier(func(_ context.Context, _ *config.GalleryVerification, _ string) error { + return errNoGallerySignature + }) + + base := tempModelsDir() + _, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "unsigned", Verification: policy}, base, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("signature")) + Expect(ociCacheEntries(base)).To(BeEmpty(), "unverified content landed in the cache") + Expect(blobs.Load()).To(BeZero(), "content was fetched before the signature was verified") + }) + + It("verifies the digest, not the tag, and only then unpacks", func() { + srv, _, _ := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/signed", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + + verified := "" + stubGalleryVerifier(func(_ context.Context, _ *config.GalleryVerification, ref string) error { + verified = ref + return nil + }) + + base := tempModelsDir() + body, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "signed", Verification: policy}, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + Expect(verified).To(ContainSubstring("@sha256:"), "the tag was verified instead of the digest it resolved to") + Expect(verified).ToNot(ContainSubstring(":latest")) + }) + }) + + It("refuses an unsigned gallery in strict integrity mode", func() { + srv, _, blobs := ociRegistry() + url := pushGalleryArtifact(srv.URL, "galleries/strict", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + resetCounters(blobs) + + base := tempModelsDir() + // Strict integrity is the --require-backend-integrity / + // LOCALAI_REQUIRE_BACKEND_INTEGRITY switch, carried here on the system + // state the listing already had. + _, _, err := fetchGalleryIndex(context.Background(), + config.Gallery{URL: url, Name: "strict"}, base, true) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("verification")) + Expect(ociCacheEntries(base)).To(BeEmpty()) + Expect(blobs.Load()).To(BeZero()) + }) + + // A pull that dies halfway leaves partial files behind. Serving those as + // if they were the gallery is worse than failing: the user gets a + // truncated index with no sign that anything went wrong. + It("does not serve a half-written pull to a later fetch", func() { + var fail atomic.Bool + fail.Store(true) + upstream := registry.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail.Load() && r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/") { + // Answer with content that does not match the descriptor, the + // way a broken proxy or a truncated transfer would. + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte("- name: trunc")) + return + } + upstream.ServeHTTP(w, r) + })) + DeferCleanup(srv.Close) + + url := pushGalleryArtifact(srv.URL, "galleries/partial", galleryArtifactType, []ociGalleryFile{ + {title: "index.yaml", body: index}, + }) + base := tempModelsDir() + g := config.Gallery{URL: url, Name: "partial"} + + _, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).To(HaveOccurred()) + Expect(ociCacheEntries(base)).To(BeEmpty(), "a failed pull left a directory a later fetch would serve") + + // With the registry healthy again the gallery must come back with the + // real index, not with whatever the failed attempt left on disk. + fail.Store(false) + resetGalleryFailures() + body, _, err := fetchGalleryIndex(context.Background(), g, base, false) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(index)) + }) +}) diff --git a/core/services/worker/worker.go b/core/services/worker/worker.go index a74e04ff7..b40980462 100644 --- a/core/services/worker/worker.go +++ b/core/services/worker/worker.go @@ -43,6 +43,7 @@ func Run(ctx *cliContext.Context, cfg *Config) error { system.WithModelPath(cfg.ModelsPath), system.WithBackendPath(cfg.BackendsPath), system.WithBackendSystemPath(cfg.BackendsSystemPath), + system.WithRequireBackendIntegrity(cfg.RequireBackendIntegrity), ) if err != nil { return fmt.Errorf("getting system state: %w", err) diff --git a/docs/content/advanced/private-sources.md b/docs/content/advanced/private-sources.md index 3f02fce07..3109d3bb7 100644 --- a/docs/content/advanced/private-sources.md +++ b/docs/content/advanced/private-sources.md @@ -84,7 +84,7 @@ The entry for such a registry needs `allow_insecure: true`, and its `match` must ## What uses the credentials -- Gallery indexes and mirrors (`galleries`, `backend_galleries`), including `github:` URLs. +- Gallery indexes and mirrors (`galleries`, `backend_galleries`), including `github:` URLs and galleries published as `oci://` artifacts. - Model files and model configs downloaded over HTTP(S) or `github:`. - Backend images and `oci://` / `ollama://` models, including resumed layer downloads and cosign signature checks. diff --git a/docs/content/features/model-gallery.md b/docs/content/features/model-gallery.md index bc8e524c6..4f0159ff0 100644 --- a/docs/content/features/model-gallery.md +++ b/docs/content/features/model-gallery.md @@ -231,6 +231,60 @@ Entries served this way may be stale: the copy is only as fresh as the last time The copy is deliberately kept out of the models directory itself, where LocalAI reads a `.yaml` file as an installed model's configuration. Deleting the cache directory is safe — the next successful fetch recreates it — and a machine that has never reached a gallery has nothing cached, so its first listing still fails. +## Galleries published as OCI artifacts + +A gallery can live in a container registry instead of on a web server. Give the gallery a `url` with the `oci://` scheme and point it at an artifact reference: + +```json +GALLERIES=[{"name":"premium", "url":"oci://quay.io/acme/gallery:latest"}] +``` + +LocalAI pulls the artifact, unpacks it into a cache directory beside your models directory (`/../cache/gallery/oci/`) and reads `index.yaml` from it. The unpacked copy is reused for one hour before the registry is asked again. Everything else works as it does for an HTTP gallery: an `oci://` URL can be a primary `url` or one of the `mirrors`, a failed pull puts the source in the same 10 minute cooldown, and the offline cache still serves the last good listing. + +Downloaded artifact files are readable and writable only by the LocalAI process owner. File writes stay inside the cache directory, including when an existing subdirectory is a symbolic link. + + +This is the only format that carries a whole gallery in one object, so it is what to publish when the index and the model configuration files must travel together. + +### Entry URLs relative to the gallery + +An artifact holds the index and the files it refers to, so an entry can name its base configuration by its place in the tree: + +```yaml +- name: premium-model + url: base/virtual.yaml +``` + +A `url` with no scheme is resolved against the root of the gallery it was read from: the unpacked artifact for an `oci://` gallery, and the directory of the index URL for an `http://`, `https://`, `github:`, `huggingface://` or `file://` gallery. A `url` that names a scheme, such as `https://example.org/base.yaml`, is always used as written. + +A relative `url` cannot leave the gallery root. An entry that tries to climb out of it, for example `url: ../../etc/passwd`, is refused: LocalAI drops that entry from the listing, logs the reason and keeps the rest of the gallery. + +### Signature verification + +An `oci://` gallery can be signed, and LocalAI verifies the signature before it unpacks anything. Add a `verification` block with the Fulcio issuer and the signing identity, in the same form the [backend galleries]({{%relref "features/backends#verifying-oci-backends" %}}) use: + +```json +GALLERIES=[{"name":"premium","url":"oci://quay.io/acme/gallery:latest","verification":{"issuer":"https://token.actions.githubusercontent.com","identity_regex":"^https://github\\.com/acme/gallery/\\.github/workflows/publish\\.yml@refs/tags/.+$"}}] +``` + +The tag is resolved to a digest, the signature is checked against that digest, and the same digest is then pulled. A gallery that fails verification is never written to the cache, so no unverified file reaches your disk. The optional `not_before` RFC3339 value revokes signatures logged before that time, exactly as it does for backends. + +{{% notice warning %}} +With `--require-backend-integrity` (`LOCALAI_REQUIRE_BACKEND_INTEGRITY=1`), an `oci://` gallery that has no `verification` block is refused when the models are listed, not only when one is installed. Add a `verification` block to every `oci://` gallery before you turn strict integrity on, or the galleries without one stop listing. An `oci://` gallery without a policy still lists outside strict mode, with a warning in the log. +{{% /notice %}} + +### Private registries + +A gallery in a private registry needs a credentials entry that matches the registry, the same entry an image pull from it would use: + +```yaml +- match: quay.io/acme + username: bot + password_env: QUAY_TOKEN +``` + +See [Private Registries and Galleries]({{% relref "advanced/private-sources" %}}) for the file location, the other authentication types and the rules for registries on a local network, which also need `allow_insecure: true`. + ## API Reference ### Model repositories diff --git a/pkg/oci/artifact.go b/pkg/oci/artifact.go new file mode 100644 index 000000000..3999aa6f8 --- /dev/null +++ b/pkg/oci/artifact.go @@ -0,0 +1,308 @@ +package oci + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/mudler/LocalAI/pkg/credentials" + "github.com/mudler/xlog" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/retry" +) + +const ( + // DefaultMaxArtifactLayers and DefaultMaxArtifactBytes bound what an + // unattended pull from a registry can cost on disk. A gallery tree is a + // handful of small YAML files, so the defaults are generous enough that a + // legitimate publisher never meets them. + DefaultMaxArtifactLayers = 1024 + DefaultMaxArtifactBytes = int64(512 << 20) +) + +// ArtifactPullOption configures PullArtifact. +type ArtifactPullOption func(*artifactPullOptions) + +type artifactPullOptions struct { + artifactType string + maxLayers int + maxBytes int64 +} + +// WithArtifactType pins the artifactType the manifest must declare. It is +// mandatory: without it a plain container image could be unpacked as if it +// were the caller's own artifact kind. +func WithArtifactType(artifactType string) ArtifactPullOption { + return func(o *artifactPullOptions) { o.artifactType = artifactType } +} + +// WithMaxArtifactLayers caps how many layers the artifact may carry. +func WithMaxArtifactLayers(layers int) ArtifactPullOption { + return func(o *artifactPullOptions) { o.maxLayers = layers } +} + +// WithMaxArtifactBytes caps the total declared size of the artifact. +func WithMaxArtifactBytes(bytes int64) ArtifactPullOption { + return func(o *artifactPullOptions) { o.maxBytes = bytes } +} + +// PullArtifact pulls an ORAS artifact and lays its layers out under dest, +// each at the relative path in its org.opencontainers.image.title annotation, +// so a published tree keeps its subdirectories. It returns the resolved +// manifest digest, which callers pin on and verify signatures against. +// +// Everything in the manifest is remote input and this writes files, so the +// whole manifest is validated before the first byte is fetched: a layer that +// would land outside dest, an unexpected artifact type or a tree over the +// caps aborts the pull with nothing written. +func PullArtifact(ctx context.Context, ref, dest string, opts ...ArtifactPullOption) (string, error) { + options := artifactPullOptions{ + maxLayers: DefaultMaxArtifactLayers, + maxBytes: DefaultMaxArtifactBytes, + } + for _, o := range opts { + o(&options) + } + if options.artifactType == "" { + return "", fmt.Errorf("no expected artifact type given for %q", ref) + } + + root, err := filepath.Abs(dest) + if err != nil { + return "", err + } + + repo, err := artifactRepository(ref) + if err != nil { + return "", err + } + + manifestDesc, rc, err := repo.FetchReference(ctx, repo.Reference.ReferenceOrDefault()) + if err != nil { + return "", fmt.Errorf("failed to resolve %q: %w", ref, err) + } + defer func() { _ = rc.Close() }() + + if manifestDesc.Size > options.maxBytes { + return "", fmt.Errorf("manifest of %q is %d bytes, over the %d byte limit", ref, manifestDesc.Size, options.maxBytes) + } + raw, err := content.ReadAll(rc, manifestDesc) + if err != nil { + return "", fmt.Errorf("failed to read the manifest of %q: %w", ref, err) + } + var manifest ocispec.Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return "", fmt.Errorf("failed to parse the manifest of %q: %w", ref, err) + } + + // An OCI 1.0 packer records the type in the config media type instead, so + // artifacts pushed by older tooling still identify themselves. + artifactType := manifest.ArtifactType + if artifactType == "" { + artifactType = manifest.Config.MediaType + } + if artifactType != options.artifactType { + return "", fmt.Errorf("%q is a %q artifact, expected %q", ref, artifactType, options.artifactType) + } + + if len(manifest.Layers) > options.maxLayers { + return "", fmt.Errorf("%q has %d layers, over the %d layer limit", ref, len(manifest.Layers), options.maxLayers) + } + + targets := make([]string, len(manifest.Layers)) + var total int64 + for i, layer := range manifest.Layers { + target, err := artifactLayerPath(root, layer.Annotations[ocispec.AnnotationTitle]) + if err != nil { + return "", fmt.Errorf("refusing layer %d of %q: %w", i, ref, err) + } + targets[i], err = filepath.Rel(root, target) + if err != nil { + return "", err + } + if layer.Size < 0 { + return "", fmt.Errorf("layer %d of %q declares a negative size", i, ref) + } + total += layer.Size + if total > options.maxBytes { + return "", fmt.Errorf("%q is at least %d bytes, over the %d byte limit", ref, total, options.maxBytes) + } + } + + if err := os.MkdirAll(root, 0o750); err != nil { + return "", err + } + // Confine directory creation and file writes even if dest contains symlinks. + output, err := os.OpenRoot(root) + if err != nil { + return "", err + } + defer func() { _ = output.Close() }() + + for i, layer := range manifest.Layers { + if err := writeArtifactLayer(ctx, repo, layer, output, targets[i]); err != nil { + return "", fmt.Errorf("failed to write layer %d of %q: %w", i, ref, err) + } + } + + return manifestDesc.Digest.String(), nil +} + +// artifactRepository builds the ORAS client a pull or a resolve talks to, with +// the credentials and the scheme detection both paths must agree on. +func artifactRepository(ref string) (*remote.Repository, error) { + repo, err := remote.NewRepository(ref) + if err != nil { + return nil, fmt.Errorf("failed to create repository: %w", err) + } + repo.SkipReferrersGC = true + // Loopback and private-network registries are reached over plain HTTP, + // matching how the go-containerregistry paths in this package resolve the + // scheme, so a local registry behaves the same whichever puller is used. + repo.PlainHTTP = plainHTTP(repo.Reference.Registry) + + client := &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + } + client.SetUserAgent(UserAgent()) + client.Credential = credentials.OrasCredential(repo.Reference.Registry + "/" + repo.Reference.Repository) + repo.Client = client + return repo, nil +} + +// ResolveArtifactDigestRef resolves ref to a digest-pinned reference of the +// form /@sha256:, without fetching any content. +// +// A caller that must verify a signature before it unpacks anything needs the +// digest first: the signature is taken over the manifest digest, and pulling +// the same digest afterwards is what makes the verification bind to the bytes +// that land on disk. Verifying a tag and then pulling that tag again would +// leave a window in which the tag moved. +func ResolveArtifactDigestRef(ctx context.Context, ref string) (string, error) { + repo, err := artifactRepository(ref) + if err != nil { + return "", err + } + desc, err := repo.Resolve(ctx, repo.Reference.ReferenceOrDefault()) + if err != nil { + return "", fmt.Errorf("failed to resolve %q: %w", ref, err) + } + return repo.Reference.Registry + "/" + repo.Reference.Repository + "@" + desc.Digest.String(), nil +} + +// plainHTTP mirrors go-containerregistry's scheme detection, which the image +// paths of this package already rely on, so both agree on which registries are +// not expected to serve TLS. +func plainHTTP(registry string) bool { + r, err := name.NewRegistry(registry) + if err != nil { + return false + } + return r.Scheme() == "http" +} + +// SafeRelativePath validates a relative path that came from outside the +// process and returns it cleaned, with forward slashes. +// +// A registry annotation and a gallery entry URL are both published data, so +// they are treated as hostile: anything that is not a plain relative path is +// refused rather than sanitized, since a path that needed rewriting is not a +// path a publisher meant. +// +// It only judges the path's shape, so callers that resolve against something +// which is not a directory on disk, such as the URL a gallery index was served +// from, can use the same rule as the ones that do. +func SafeRelativePath(relative string) (string, error) { + if relative == "" { + return "", fmt.Errorf("the path is empty") + } + if strings.ContainsAny(relative, "\\\x00") { + return "", fmt.Errorf("%q contains a backslash or NUL byte that is not valid in a relative path", relative) + } + if path.IsAbs(relative) || filepath.IsAbs(relative) || filepath.VolumeName(relative) != "" { + return "", fmt.Errorf("%q is an absolute path", relative) + } + clean := path.Clean(relative) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("%q escapes the root directory", relative) + } + return clean, nil +} + +// ResolveInRoot resolves a relative path against root and refuses anything +// that would land outside it. root is expected to be absolute and already +// cleaned, which is what its callers hold. +func ResolveInRoot(root, relative string) (string, error) { + clean, err := SafeRelativePath(relative) + if err != nil { + return "", err + } + target := filepath.Join(root, filepath.FromSlash(clean)) + // filepath.Join cleans the result, so this catches anything the shape + // check could not see, a root with its own traversal in it included. + if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) { + return "", fmt.Errorf("%q escapes the root directory", relative) + } + return target, nil +} + +// artifactLayerPath resolves a layer title against root. +func artifactLayerPath(root, title string) (string, error) { + if title == "" { + return "", fmt.Errorf("the layer has no %s title", ocispec.AnnotationTitle) + } + target, err := ResolveInRoot(root, title) + if err != nil { + return "", fmt.Errorf("the layer title is not usable: %w", err) + } + return target, nil +} + +func writeArtifactLayer(ctx context.Context, repo *remote.Repository, layer ocispec.Descriptor, root *os.Root, target string) error { + if err := root.MkdirAll(filepath.Dir(target), 0o750); err != nil { + return err + } + blob, err := repo.Fetch(ctx, layer) + if err != nil { + return err + } + defer func() { _ = blob.Close() }() + + // O_EXCL keeps the write from following a symlink already sitting at the + // target, and makes two layers claiming the same title an error instead of + // a silent overwrite. + f, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + + // A registry that serves more bytes than the descriptor declares would + // otherwise blow past the size cap already accounted for. + verifier := content.NewVerifyReader(io.LimitReader(blob, layer.Size+1), layer) + _, err = io.Copy(f, verifier) + if err == nil { + err = verifier.Verify() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + // Content that failed to verify must not be left behind for a caller + // to read as if it were the published layer. + if rmErr := root.Remove(target); rmErr != nil { + xlog.Debug("Could not remove a partially written artifact layer", "path", target, "error", rmErr) + } + return err + } + return nil +} diff --git a/pkg/oci/artifact_test.go b/pkg/oci/artifact_test.go new file mode 100644 index 000000000..23d0b15fb --- /dev/null +++ b/pkg/oci/artifact_test.go @@ -0,0 +1,221 @@ +package oci_test + +import ( + "bytes" + "context" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/registry" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry/remote" + + localoci "github.com/mudler/LocalAI/pkg/oci" +) + +const testGalleryArtifactType = "application/vnd.localai.gallery.v1" + +// artifactFile is one layer of a test artifact: the title annotation the +// puller lays the content out by, and the content itself. +type artifactFile struct { + title string + body string +} + +// pushTestArtifact publishes an ORAS artifact to the in-process registry and +// returns its reference and manifest digest, so specs can assert on the digest +// the puller reports back. +func pushTestArtifact(serverURL, repoPath, artifactType string, files []artifactFile) (string, string) { + host := strings.TrimPrefix(serverURL, "http://") + ctx := context.Background() + + store := memory.New() + layers := []ocispec.Descriptor{} + for _, f := range files { + body := []byte(f.body) + desc := content.NewDescriptorFromBytes("application/yaml", body) + if f.title != "" { + desc.Annotations = map[string]string{ocispec.AnnotationTitle: f.title} + } + Expect(store.Push(ctx, desc, bytes.NewReader(body))).To(Succeed()) + layers = append(layers, desc) + } + + manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, artifactType, oras.PackManifestOptions{ + Layers: layers, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(store.Tag(ctx, manifestDesc, "latest")).To(Succeed()) + + repo, err := remote.NewRepository(host + "/" + repoPath) + Expect(err).NotTo(HaveOccurred()) + repo.PlainHTTP = true + _, err = oras.Copy(ctx, store, "latest", repo, "latest", oras.DefaultCopyOptions) + Expect(err).NotTo(HaveOccurred()) + + return host + "/" + repoPath + ":latest", manifestDesc.Digest.String() +} + +var _ = Describe("PullArtifact", func() { + var ( + server *httptest.Server + dest string + ) + + BeforeEach(func() { + server = httptest.NewServer(registry.New()) + DeferCleanup(server.Close) + // A nested destination keeps an escaping title landing in a fresh + // directory of this spec's own, not in the shared temp root. + dest = filepath.Join(GinkgoT().TempDir(), "tree") + Expect(os.MkdirAll(dest, 0o750)).To(Succeed()) + }) + + It("writes every layer under the destination, preserving subdirectories", func() { + ref, manifestDigest := pushTestArtifact(server.URL, "galleries/premium", testGalleryArtifactType, []artifactFile{ + {title: "index.yaml", body: "- name: one\n"}, + {title: "base/virtual.yaml", body: "- name: two\n"}, + }) + + digest, err := localoci.PullArtifact(context.Background(), ref, dest, + localoci.WithArtifactType(testGalleryArtifactType)) + Expect(err).NotTo(HaveOccurred()) + Expect(digest).To(Equal(manifestDigest)) + + Expect(os.ReadFile(filepath.Join(dest, "index.yaml"))).To(BeEquivalentTo("- name: one\n")) + Expect(os.ReadFile(filepath.Join(dest, "base", "virtual.yaml"))).To(BeEquivalentTo("- name: two\n")) + for _, relative := range []string{"index.yaml", "base/virtual.yaml"} { + info, err := os.Stat(filepath.Join(dest, relative)) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm() & 0o077).To(BeZero()) + } + }) + + It("refuses writes through a directory symlink outside the destination", func() { + outside := GinkgoT().TempDir() + Expect(os.Symlink(outside, filepath.Join(dest, "linked"))).To(Succeed()) + ref, _ := pushTestArtifact(server.URL, "galleries/symlink", testGalleryArtifactType, []artifactFile{ + {title: "linked/escape.yaml", body: "owned\n"}, + }) + _, err := localoci.PullArtifact(context.Background(), ref, dest, localoci.WithArtifactType(testGalleryArtifactType)) + Expect(err).To(HaveOccurred()) + Expect(filepath.Join(outside, "escape.yaml")).NotTo(BeAnExistingFile()) + }) + + It("refuses a layer whose title escapes the destination with ..", func() { + ref, _ := pushTestArtifact(server.URL, "galleries/escape", testGalleryArtifactType, []artifactFile{ + {title: "../escape.yaml", body: "owned\n"}, + }) + + _, err := localoci.PullArtifact(context.Background(), ref, dest, + localoci.WithArtifactType(testGalleryArtifactType)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("../escape.yaml")) + Expect(filepath.Join(filepath.Dir(dest), "escape.yaml")).NotTo(BeAnExistingFile()) + }) + + It("refuses a layer whose title is an absolute path", func() { + outside := filepath.Join(GinkgoT().TempDir(), "absolute.yaml") + ref, _ := pushTestArtifact(server.URL, "galleries/absolute", testGalleryArtifactType, []artifactFile{ + {title: outside, body: "owned\n"}, + }) + + _, err := localoci.PullArtifact(context.Background(), ref, dest, + localoci.WithArtifactType(testGalleryArtifactType)) + Expect(err).To(HaveOccurred()) + Expect(outside).NotTo(BeAnExistingFile()) + }) + + It("refuses a layer that carries no title", func() { + ref, _ := pushTestArtifact(server.URL, "galleries/untitled", testGalleryArtifactType, []artifactFile{ + {title: "", body: "untitled\n"}, + }) + + _, err := localoci.PullArtifact(context.Background(), ref, dest, + localoci.WithArtifactType(testGalleryArtifactType)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("title")) + entries, readErr := os.ReadDir(dest) + Expect(readErr).NotTo(HaveOccurred()) + Expect(entries).To(BeEmpty()) + }) + + It("refuses an artifact published under a different artifact type", func() { + ref, _ := pushTestArtifact(server.URL, "galleries/wrongtype", "application/vnd.localai.backend.v1", []artifactFile{ + {title: "index.yaml", body: "- name: one\n"}, + }) + + _, err := localoci.PullArtifact(context.Background(), ref, dest, + localoci.WithArtifactType(testGalleryArtifactType)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("application/vnd.localai.backend.v1")) + Expect(filepath.Join(dest, "index.yaml")).NotTo(BeAnExistingFile()) + }) + + It("refuses an artifact larger than the total size cap", func() { + ref, _ := pushTestArtifact(server.URL, "galleries/toobig", testGalleryArtifactType, []artifactFile{ + {title: "index.yaml", body: strings.Repeat("a", 4096)}, + }) + + _, err := localoci.PullArtifact(context.Background(), ref, dest, + localoci.WithArtifactType(testGalleryArtifactType), + localoci.WithMaxArtifactBytes(1024)) + Expect(err).To(HaveOccurred()) + Expect(filepath.Join(dest, "index.yaml")).NotTo(BeAnExistingFile()) + }) + + It("refuses an artifact with more layers than the layer cap", func() { + ref, _ := pushTestArtifact(server.URL, "galleries/toomany", testGalleryArtifactType, []artifactFile{ + {title: "index.yaml", body: "- name: one\n"}, + {title: "base/virtual.yaml", body: "- name: two\n"}, + {title: "base/extra.yaml", body: "- name: three\n"}, + }) + + _, err := localoci.PullArtifact(context.Background(), ref, dest, + localoci.WithArtifactType(testGalleryArtifactType), + localoci.WithMaxArtifactLayers(2)) + Expect(err).To(HaveOccurred()) + Expect(filepath.Join(dest, "index.yaml")).NotTo(BeAnExistingFile()) + }) +}) + +var _ = Describe("ResolveArtifactDigestRef", func() { + var server *httptest.Server + + BeforeEach(func() { + server = httptest.NewServer(registry.New()) + DeferCleanup(server.Close) + }) + + It("turns a tag into the digest the signature is taken over", func() { + ref, manifestDigest := pushTestArtifact(server.URL, "galleries/resolve", testGalleryArtifactType, []artifactFile{ + {title: "index.yaml", body: "- name: one\n"}, + }) + + digestRef, err := localoci.ResolveArtifactDigestRef(context.Background(), ref) + Expect(err).NotTo(HaveOccurred()) + Expect(digestRef).To(Equal(strings.TrimSuffix(ref, ":latest") + "@" + manifestDigest)) + + // The pinned reference must be pullable as it stands, since that is + // what a verified caller pulls once the signature checks out. + dest := filepath.Join(GinkgoT().TempDir(), "pinned") + Expect(os.MkdirAll(dest, 0o750)).To(Succeed()) + _, err = localoci.PullArtifact(context.Background(), digestRef, dest, + localoci.WithArtifactType(testGalleryArtifactType)) + Expect(err).NotTo(HaveOccurred()) + Expect(os.ReadFile(filepath.Join(dest, "index.yaml"))).To(BeEquivalentTo("- name: one\n")) + }) + + It("reports a reference that does not exist", func() { + host := strings.TrimPrefix(server.URL, "http://") + _, err := localoci.ResolveArtifactDigestRef(context.Background(), host+"/galleries/absent:latest") + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/pkg/system/state.go b/pkg/system/state.go index 2ad52b7d3..e87df528a 100644 --- a/pkg/system/state.go +++ b/pkg/system/state.go @@ -30,6 +30,13 @@ type SystemState struct { // backend URI (the released image becomes a fallback) rather than only using // development as a download fallback when the released image is missing. PreferDevelopmentBackends bool + + // RequireBackendIntegrity is the strict-integrity switch bound to + // --require-backend-integrity / LOCALAI_REQUIRE_BACKEND_INTEGRITY. The + // backend install path takes it as a call argument; the gallery index + // fetch reads it from here because the listing code it runs under never + // sees the application config. + RequireBackendIntegrity bool } type SystemStateOptions func(*SystemState) @@ -70,6 +77,12 @@ func WithBackendDevSuffix(suffix string) SystemStateOptions { } } +func WithRequireBackendIntegrity(require bool) SystemStateOptions { + return func(s *SystemState) { + s.RequireBackendIntegrity = require + } +} + func WithPreferDevelopmentBackends(prefer bool) SystemStateOptions { return func(s *SystemState) { s.PreferDevelopmentBackends = prefer