feat(gallery): resolve entry urls relative to the gallery root

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. Such a
url reached the HTTP client verbatim and no entry of the gallery could
be installed.

A scheme-less entry url now resolves against the root the index came
from: the unpacked artifact for an oci:// gallery, and the directory of
the index URL otherwise. The relative path may not climb out of that
root, reusing the guard the artifact puller already applies to layer
titles, and an entry that does is dropped from the listing rather than
failing the whole gallery. An entry that names its own scheme is
untouched, so .ref indirection and local github: resolution behave as
before.

Reading an entry of an OCI gallery also needs the unpacked artifact
directory as its trusted root, since the cache is deliberately a sibling
of the models directory the downloader otherwise confines a file read
to.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]
This commit is contained in:
Ettore Di Giacinto committed 2026-09-20 22:17:17 +00:00
1 parent 22b146ab2b
commit e7a8ebe531
7 files changed
+387 -22

No files matched your search

+139
View File
@@ -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
}
+20 -2
View File
@@ -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).
+110
View File
@@ -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())
})
})
+17 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+51
View File
@@ -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 (`<MODELS_PATH>/../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
+49 -17
View File
@@ -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
}