Files
LocalAI/core/gallery/entry_url.go
T
237cf456fd feat(gallery): galleries published as OCI artifacts (#12167)
* feat(oci): pull ORAS artifacts into a directory

Galleries published to an OCI registry arrive as an ORAS artifact whose
layers carry their tree path in org.opencontainers.image.title. Nothing in
pkg/oci could read a non-image manifest, so add PullArtifact: it lays the
layers out under a destination directory and returns the manifest digest so
callers can pin and verify signatures on it.

A registry is remote input and this writes files, so the manifest is
validated before any byte is fetched. A title that is absolute, escapes the
destination or is missing is refused, an artifact of an unexpected
artifactType is refused, and both the layer count and the total size are
capped with caller-settable limits.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]

* feat(gallery): fetch and verify galleries published as OCI artifacts

A gallery whose URL is oci://host/repo:tag is now pulled as an ORAS
artifact and its index.yaml read from the unpacked tree, alongside the
existing HTTP and file sources. Mirrors, the per-candidate timeout, the
failure cooldown and the last known good copy work the same for both
schemes, so a gallery can mirror an OCI primary with an HTTP fallback.

When the gallery declares a verification policy the artifact reference is
resolved to its manifest digest, the publisher signature is checked
against that digest, and only then is the same digest pulled. Nothing
unverified is written to disk or parsed. With strict integrity mode on,
an OCI gallery without a verification policy is refused instead of
warned about, which is what the flag already does for backend installs.

The pull lands in a staging directory that is renamed into the cache only
once the whole tree is on disk and the index reads back as an index, so a
failed or interrupted pull leaves nothing a later fetch would serve. A
fetch that finds a fresh cache entry does not contact the registry.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]

* 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]

* fix(oci): restrict artifact file permissions

Create artifact files with owner-only permissions to satisfy G302.
Document the existing path validation for the G304 scanner finding.
Check the permissions of downloaded layers in the artifact test.

Assisted-by: Codex:gpt-6 gosec

* fix(oci): confine artifact writes to their root

Use root-relative filesystem operations to reject escapes through existing
directory symlinks. Retain owner-only permissions and add a symlink escape
regression test, without suppressing the path traversal finding.

Assisted-by: Codex:gpt-6

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-21 09:23:05 +02:00

140 lines
5.1 KiB
Go

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
}