Files
LocalAI/pkg/oci/artifact.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

309 lines
11 KiB
Go

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 <registry>/<repository>@sha256:<hex>, 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
}