Files
LocalAI/pkg/downloader/uri_test.go
T
mudler-agentandEttore Di Giacinto 543fb4bd24 fix(gallery): verification follow-ups for oci:// galleries (#12243)
* fix(gallery): verification follow-ups for oci:// galleries

Follow-ups from the post-merge review of #12238 and #12239.

Only a policy decision is a refusal now. cosignverify wraps
ErrPolicyRejected around a failed signature check, an identity or
source-repository mismatch, a not_before cutoff and a missing or
unparseable bundle. A TUF, registry or network failure during
verification, or a timeout, is an outage: the gallery falls back to the
copy verified under the current policy, as it does when the registry is
down.

An oci:// gallery with a verification block, or any oci:// gallery under
strict integrity, is no longer answered by an https://, github: or
file:// mirror. Such a mirror is ignored with a warning, because nothing
can check its signature. The index of an HTTP gallery, whose policy only
covers its backend images, is cached under the URL-only name again, so no
unchecked body is stored under a policy-keyed name.

The in-memory index cache key now includes the policy. After a runtime
policy change the index is fetched again, and entries with a relative url
install again.

The registry digest lookups after install and upgrade, and in the
upgrade check, run only for real registry references (new
URI.LooksLikeRegistryOCI), not for ollama:// or ocifile://.

The refusal message names strict integrity when that is the cause, and
the gallery name is no longer repeated.

Specs pin the URL-only cache name for galleries without a policy, a fixed
key for a fixed policy, and that every GalleryVerification field changes
the key. The docs describe refusal, outage, mirrors and strict integrity.

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

* fix(gallery): reset listings on gallery changes, classify referrer outages

Review follow-ups for this PR.

The React UI lists from AvailableGalleryModelsCached, which is keyed by
nothing. A gallery change through the settings API or a
runtime_settings.json edit now drops that listing when the model or
backend gallery configuration differs. Before, the UI kept the old list,
with local paths into the old policy's tree, until the next background
refresh, or for good when the new policy refused the gallery.

In cosignverify, a referrer the registry fails to serve now makes the
lookup an outage whatever other referrers failed and in any order, since
the unread one may be the valid signature. An invalid policy (Validate in
NewVerifier, an unparseable not_before) is ErrPolicyRejected, because no
fetch can make it usable.

The docs say that only an oci:// gallery with a verification block skips
non-OCI mirrors, and list an unusable policy as a refusal.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-24 21:20:21 +02:00

381 lines
13 KiB
Go

package downloader_test
import (
"context"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
. "github.com/mudler/LocalAI/pkg/downloader"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Gallery API tests", func() {
Context("URI", func() {
It("parses github with a branch", func() {
uri := URI("github:go-skynet/model-gallery/gpt4all-j.yaml")
Expect(
uri.ReadWithCallback("", func(url string, i []byte) error {
Expect(url).To(Equal("https://raw.githubusercontent.com/go-skynet/model-gallery/main/gpt4all-j.yaml"))
return nil
}),
).ToNot(HaveOccurred())
})
It("parses github without a branch", func() {
uri := URI("github:go-skynet/model-gallery/gpt4all-j.yaml@main")
Expect(
uri.ReadWithCallback("", func(url string, i []byte) error {
Expect(url).To(Equal("https://raw.githubusercontent.com/go-skynet/model-gallery/main/gpt4all-j.yaml"))
return nil
}),
).ToNot(HaveOccurred())
})
It("parses github with urls", func() {
uri := URI("https://raw.githubusercontent.com/go-skynet/model-gallery/main/gpt4all-j.yaml")
Expect(
uri.ReadWithCallback("", func(url string, i []byte) error {
Expect(url).To(Equal("https://raw.githubusercontent.com/go-skynet/model-gallery/main/gpt4all-j.yaml"))
return nil
}),
).ToNot(HaveOccurred())
})
})
Context("HuggingFace mirror", func() {
var originalEndpoint string
BeforeEach(func() {
originalEndpoint = HF_ENDPOINT
})
AfterEach(func() {
HF_ENDPOINT = originalEndpoint
})
It("rewrites direct https://huggingface.co URLs when mirror is set", func() {
HF_ENDPOINT = "https://hf-mirror.com"
uri := URI("https://huggingface.co/TheBloke/model-GGUF/resolve/main/model.Q4_K_M.gguf")
Expect(uri.ResolveURL()).To(Equal("https://hf-mirror.com/TheBloke/model-GGUF/resolve/main/model.Q4_K_M.gguf"))
})
It("does not rewrite direct https://huggingface.co URLs when no mirror is set", func() {
HF_ENDPOINT = "https://huggingface.co"
uri := URI("https://huggingface.co/TheBloke/model-GGUF/resolve/main/model.Q4_K_M.gguf")
Expect(uri.ResolveURL()).To(Equal("https://huggingface.co/TheBloke/model-GGUF/resolve/main/model.Q4_K_M.gguf"))
})
It("rewrites hf:// URIs when mirror is set", func() {
HF_ENDPOINT = "https://hf-mirror.com"
uri := URI("hf://TheBloke/model-GGUF/model.Q4_K_M.gguf")
Expect(uri.ResolveURL()).To(Equal("https://hf-mirror.com/TheBloke/model-GGUF/resolve/main/model.Q4_K_M.gguf"))
})
It("does not rewrite non-huggingface URLs", func() {
HF_ENDPOINT = "https://hf-mirror.com"
uri := URI("https://example.com/some/file.gguf")
Expect(uri.ResolveURL()).To(Equal("https://example.com/some/file.gguf"))
})
})
})
var _ = Describe("OCIReference", func() {
DescribeTable("returns the registry reference without the oci:// scheme",
func(uri, want string) {
Expect(URI(uri).OCIReference()).To(Equal(want))
},
Entry("tag form", "oci://registry.example.com/acme/backend:v1", "registry.example.com/acme/backend:v1"),
Entry("digest form", "oci://registry.example.com:5000/acme/backend@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "registry.example.com:5000/acme/backend@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
Entry("reference without a scheme", "quay.io/acme/backend:latest", "quay.io/acme/backend:latest"),
)
})
var _ = Describe("LooksLikeRegistryOCI", func() {
DescribeTable("accepts only URIs that name an image in a registry",
func(uri string, want bool) {
Expect(URI(uri).LooksLikeRegistryOCI()).To(Equal(want))
},
Entry("oci:// reference", "oci://registry.example.com/acme/backend:v1", true),
Entry("bare quay.io reference", "quay.io/acme/backend:latest", true),
Entry("ollama:// model", "ollama://gemma:2b", false),
Entry("ocifile:// tarball", "ocifile:///srv/backend.tar", false),
Entry("https URL", "https://example.com/backend.tar", false),
)
})
var _ = Describe("ContentLength", func() {
Context("local file", func() {
It("returns file size for existing file", func() {
dir, err := os.MkdirTemp("", "contentlength-*")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(dir)
fpath := filepath.Join(dir, "model.gguf")
err = os.WriteFile(fpath, make([]byte, 1234), 0644)
Expect(err).ToNot(HaveOccurred())
uri := URI("file://" + fpath)
ctx := context.Background()
size, err := uri.ContentLength(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(int64(1234)))
})
It("returns error for missing file", func() {
uri := URI("file:///nonexistent/path/model.gguf")
ctx := context.Background()
_, err := uri.ContentLength(ctx)
Expect(err).To(HaveOccurred())
})
})
Context("HTTP", func() {
It("returns Content-Length when present", func() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.Method).To(Equal("HEAD"))
w.Header().Set("Content-Length", "1000")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
uri := URI(server.URL)
ctx := context.Background()
size, err := uri.ContentLength(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(int64(1000)))
})
It("returns error on 404", func() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
uri := URI(server.URL)
ctx := context.Background()
_, err := uri.ContentLength(ctx)
Expect(err).To(HaveOccurred())
})
It("uses Range when Content-Length missing and Accept-Ranges bytes", func() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "HEAD" {
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusOK)
return
}
Expect(r.Header.Get("Range")).To(Equal("bytes=0-0"))
w.Header().Set("Content-Range", "bytes 0-0/5000")
w.WriteHeader(http.StatusPartialContent)
}))
defer server.Close()
uri := URI(server.URL)
ctx := context.Background()
size, err := uri.ContentLength(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(size).To(Equal(int64(5000)))
})
It("respects context cancellation", func() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1000")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
uri := URI(server.URL)
_, err := uri.ContentLength(ctx)
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, context.Canceled)).To(BeTrue())
})
})
})
type RangeHeaderError struct {
msg string
}
func (e *RangeHeaderError) Error() string { return e.msg }
var _ = Describe("Download Test", func() {
var mockData []byte
var mockDataSha string
var filePath string
extractRangeHeader := func(rangeString string) (int, int, error) {
regex := regexp.MustCompile(`^bytes=(\d+)-(\d+|)$`)
matches := regex.FindStringSubmatch(rangeString)
rangeErr := RangeHeaderError{msg: "invalid / ill-formatted range"}
if matches == nil {
return -1, -1, &rangeErr
}
startPos, err := strconv.Atoi(matches[1])
if err != nil {
return -1, -1, err
}
endPos := -1
if matches[2] != "" {
endPos, err = strconv.Atoi(matches[2])
if err != nil {
return -1, -1, err
}
endPos += 1 // because range is inclusive in rangeString
}
return startPos, endPos, nil
}
getMockServer := func(supportsRangeHeader bool) *httptest.Server {
mockServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "HEAD" && r.Method != "GET" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method == "HEAD" {
if supportsRangeHeader {
w.Header().Add("Accept-Ranges", "bytes")
}
w.WriteHeader(http.StatusOK)
return
}
// GET method
startPos := 0
endPos := len(mockData)
var err error
var respData []byte
rangeString := r.Header.Get("Range")
if rangeString != "" {
startPos, endPos, err = extractRangeHeader(rangeString)
if err != nil {
if _, ok := err.(*RangeHeaderError); ok {
w.WriteHeader(http.StatusBadRequest)
return
}
Expect(err).ToNot(HaveOccurred())
}
if endPos == -1 {
endPos = len(mockData)
}
if startPos < 0 || startPos >= len(mockData) || endPos < 0 || endPos > len(mockData) || startPos > endPos {
w.WriteHeader(http.StatusBadRequest)
return
}
}
respData = mockData[startPos:endPos]
w.Header().Set("Content-Length", strconv.Itoa(len(respData)))
if rangeString != "" {
w.Header().Set(
"Content-Range",
fmt.Sprintf("bytes %d-%d/%d", startPos, endPos-1, len(mockData)),
)
w.WriteHeader(http.StatusPartialContent)
} else {
w.WriteHeader(http.StatusOK)
}
_, _ = w.Write(respData)
}))
mockServer.EnableHTTP2 = true
mockServer.Start()
return mockServer
}
BeforeEach(func() {
mockData = make([]byte, 20000)
_, err := rand.Read(mockData)
Expect(err).ToNot(HaveOccurred())
_mockDataSha := sha256.New()
_, err = _mockDataSha.Write(mockData)
Expect(err).ToNot(HaveOccurred())
mockDataSha = fmt.Sprintf("%x", _mockDataSha.Sum(nil))
dir, err := os.Getwd()
filePath = dir + "/my_supercool_model"
Expect(err).NotTo(HaveOccurred())
})
Context("URI DownloadFile", func() {
It("fetches files from mock server", func() {
mockServer := getMockServer(true)
defer mockServer.Close()
uri := URI(mockServer.URL)
err := uri.DownloadFile(filePath, mockDataSha, 1, 1, func(s1, s2, s3 string, f float64) {})
Expect(err).ToNot(HaveOccurred())
})
It("resumes partially downloaded files", func() {
mockServer := getMockServer(true)
defer mockServer.Close()
uri := URI(mockServer.URL)
// Create a partial file
tmpFilePath := filePath + ".partial"
file, err := os.OpenFile(tmpFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
Expect(err).ToNot(HaveOccurred())
_, err = file.Write(mockData[0:10000])
Expect(err).ToNot(HaveOccurred())
err = uri.DownloadFile(filePath, mockDataSha, 1, 1, func(s1, s2, s3 string, f float64) {})
Expect(err).ToNot(HaveOccurred())
})
It("restarts download from 0 if server doesn't support Range header", func() {
mockServer := getMockServer(false)
defer mockServer.Close()
uri := URI(mockServer.URL)
// Create a partial file
tmpFilePath := filePath + ".partial"
file, err := os.OpenFile(tmpFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
Expect(err).ToNot(HaveOccurred())
_, err = file.Write(mockData[0:10000])
Expect(err).ToNot(HaveOccurred())
err = uri.DownloadFile(filePath, mockDataSha, 1, 1, func(s1, s2, s3 string, f float64) {})
Expect(err).ToNot(HaveOccurred())
})
// A file that fails its SHA check must not be usable. The historical
// implementation renamed the temp file to its final path *before*
// verifying the hash, so a mismatch returned an error but left a
// tampered file at the destination — the next caller (e.g. a backend
// launcher) could pick it up and run with it.
It("does not leave a corrupted file at the destination on SHA mismatch", func() {
mockServer := getMockServer(true)
defer mockServer.Close()
uri := URI(mockServer.URL)
// Use a clearly-wrong expected SHA; the server will return real
// data with a different hash.
wrongSHA := "0000000000000000000000000000000000000000000000000000000000000000"
err := uri.DownloadFile(filePath, wrongSHA, 1, 1, func(s1, s2, s3 string, f float64) {})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("SHA"))
// The file must not exist at the final destination.
_, statErr := os.Stat(filePath)
Expect(os.IsNotExist(statErr)).To(BeTrue(),
"download with wrong SHA left a file at %s — a subsequent caller could load tampered content", filePath)
})
// A download without an expected digest is a supply-chain footgun.
// The downloader allows it (backend installs pass through here
// today and don't yet ship a digest) but it is the caller's
// responsibility to know when integrity is required. The downloader
// emits a WARN log on every empty-digest download to make this
// visible at the default log level.
It("succeeds with empty SHA but emits an integrity warning", func() {
mockServer := getMockServer(true)
defer mockServer.Close()
uri := URI(mockServer.URL)
// No assertion on logs (we don't capture xlog output here),
// but the call must succeed so existing backend installs do
// not regress.
err := uri.DownloadFile(filePath, "", 1, 1, func(s1, s2, s3 string, f float64) {})
Expect(err).ToNot(HaveOccurred())
_, statErr := os.Stat(filePath)
Expect(statErr).ToNot(HaveOccurred())
})
})
AfterEach(func() {
os.Remove(filePath) // cleanup, also checks existence of filePath`
os.Remove(filePath + ".partial")
})
})