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 ffe250eed..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 { @@ -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). 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_oci.go b/core/gallery/gallery_oci.go index 11735ecf6..27915dc6f 100644 --- a/core/gallery/gallery_oci.go +++ b/core/gallery/gallery_oci.go @@ -79,11 +79,26 @@ func looksLikeOCIGallery(candidate string) bool { // for a non-absolute models directory because only an absolute one names a // location we can reason about. func ociGalleryCacheDir(basePath, url string) string { - if !filepath.IsAbs(basePath) { + root := ociGalleryCacheRoot(basePath) + if root == "" { return "" } sum := sha256.Sum256([]byte(url)) - return filepath.Join(basePath, "..", "cache", "gallery", "oci", hex.EncodeToString(sum[:])) + 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 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..9cd100986 100644 --- a/docs/content/features/model-gallery.md +++ b/docs/content/features/model-gallery.md @@ -231,6 +231,57 @@ 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. + +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 index 3048c05c9..9e918fa9e 100644 --- a/pkg/oci/artifact.go +++ b/pkg/oci/artifact.go @@ -198,27 +198,59 @@ func plainHTTP(registry string) bool { return r.Scheme() == "http" } -// artifactLayerPath resolves a layer title against root. The title comes from -// the registry, so it is treated as hostile: anything that is not a plain -// relative path inside root is refused rather than sanitized, since a title -// that needed rewriting is not a title a publisher meant. +// 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) } - if strings.ContainsAny(title, "\\\x00") { - return "", fmt.Errorf("title %q contains a path separator or NUL byte that is not valid in an artifact title", title) - } - if path.IsAbs(title) || filepath.IsAbs(title) || filepath.VolumeName(title) != "" { - return "", fmt.Errorf("title %q is an absolute path", title) - } - clean := path.Clean(title) - if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { - return "", fmt.Errorf("title %q escapes the destination directory", title) - } - target := filepath.Join(root, filepath.FromSlash(clean)) - if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) { - return "", fmt.Errorf("title %q escapes the destination directory", title) + target, err := ResolveInRoot(root, title) + if err != nil { + return "", fmt.Errorf("the layer title is not usable: %w", err) } return target, nil }