mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 11:30:44 -04:00
* fix(backends): fall back to copying links when the filesystem rejects symlinks (#10890) Backend installation extracts the OCI image tar via containerd's archive.Apply, which calls os.Symlink directly. On filesystems that do not support symlinks (notably CIFS/SMB mounts, commonly used to back the /backends volume) the syscall fails with "operation not supported" and the whole install aborts, leaving an empty backend directory. The CUDA llama.cpp image trips this on the libcublas.so -> libcublas.so.12.x symlink. When archive.Apply fails with a link-unsupported error, reset the staging directory and re-extract with a pure-Go walker that still attempts real symlinks/hardlinks first and degrades to copying the link target's contents in place when the filesystem rejects them. mutate.Extract already flattened the layers, so the tar carries no whiteouts to interpret. Link copies are deferred to a second pass so forward references resolve. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): check deferred Close in copyFilePreservingMode (errcheck) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): reject path-traversal tar entries in the link-copy fallback safeJoin sanitized "../.." entries by clamping them under root instead of rejecting them, so a malicious entry was silently redirected rather than refused. Join without the leading-slash trick and reject any entry whose cleaned path resolves outside root; absolute link targets are still mapped under root (image-root relative) rather than escaping. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): silence gosec on the validated link-copy file ops Use hdr.FileInfo().Mode() instead of converting the int64 tar mode to os.FileMode (removes two G115 overflow findings), and annotate the tar extraction file operations with justified #nosec comments: every path is validated by safeJoin against the extraction root before use (G304/G305). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:opus-4.8 [Claude Code] * fix(oci): build extraction image from downloaded layers Avoid appending downloaded layers to the original remote-backed image, which duplicates the layer stack and reopens the source during extraction. Building from an empty image preserves the flattened whiteout semantics while keeping extraction local. Assisted-by: Codex:gpt-5 * fix(oci): materialize chained links in dependency order Retry deferred link copies until their targets exist so soname chains work on filesystems without symlink support. Document that copied links can increase backend storage usage on CIFS and SMB mounts. Assisted-by: Codex:gpt-5 --------- 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>
232 lines
7.6 KiB
Go
232 lines
7.6 KiB
Go
package oci
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"syscall"
|
|
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
|
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
|
"github.com/google/go-containerregistry/pkg/v1/tarball"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
type compressedOnlyLayer struct {
|
|
v1.Layer
|
|
digest v1.Hash
|
|
}
|
|
|
|
func (l compressedOnlyLayer) Uncompressed() (io.ReadCloser, error) {
|
|
return nil, errors.New("downloaded layer reopened from source")
|
|
}
|
|
|
|
func (l compressedOnlyLayer) Digest() (v1.Hash, error) { return l.digest, nil }
|
|
|
|
func buildLayer(entries ...tar.Header) v1.Layer {
|
|
var buf bytes.Buffer
|
|
zw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(zw)
|
|
for _, header := range entries {
|
|
content := []byte(header.PAXRecords["content"])
|
|
header.PAXRecords = nil
|
|
header.Size = int64(len(content))
|
|
Expect(tw.WriteHeader(&header)).To(Succeed())
|
|
if len(content) != 0 {
|
|
_, err := tw.Write(content)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
}
|
|
}
|
|
Expect(tw.Close()).To(Succeed())
|
|
Expect(zw.Close()).To(Succeed())
|
|
layer, err := tarball.LayerFromReader(bytes.NewReader(buf.Bytes()))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
digest, _, err := v1.SHA256(bytes.NewReader([]byte{byte(len(entries))}))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
return compressedOnlyLayer{Layer: layer, digest: digest}
|
|
}
|
|
|
|
// buildTar assembles an in-memory tar carrying a directory, a regular file and
|
|
// a relative symlink pointing at that file, mirroring the layout of a backend
|
|
// image (e.g. libcublas.so -> libcublas.so.12).
|
|
func buildTar() []byte {
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
Expect(tw.WriteHeader(&tar.Header{
|
|
Name: "lib/",
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0755,
|
|
})).To(Succeed())
|
|
|
|
content := []byte("real library bytes")
|
|
Expect(tw.WriteHeader(&tar.Header{
|
|
Name: "lib/libcublas.so.12",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0644,
|
|
Size: int64(len(content)),
|
|
})).To(Succeed())
|
|
_, err := tw.Write(content)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
Expect(tw.WriteHeader(&tar.Header{
|
|
Name: "lib/libcublas.so",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "libcublas.so.12",
|
|
Mode: 0777,
|
|
})).To(Succeed())
|
|
|
|
Expect(tw.Close()).To(Succeed())
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func buildChainedLinkTar() []byte {
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
content := []byte("real library bytes")
|
|
Expect(tw.WriteHeader(&tar.Header{
|
|
Name: "lib/libcublas.so",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "libcublas.so.12",
|
|
Mode: 0777,
|
|
})).To(Succeed())
|
|
Expect(tw.WriteHeader(&tar.Header{
|
|
Name: "lib/libcublas.so.12",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "libcublas.so.12.8.5.5",
|
|
Mode: 0777,
|
|
})).To(Succeed())
|
|
Expect(tw.WriteHeader(&tar.Header{
|
|
Name: "lib/libcublas.so.12.8.5.5",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0644,
|
|
Size: int64(len(content)),
|
|
})).To(Succeed())
|
|
_, err := tw.Write(content)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
Expect(tw.Close()).To(Succeed())
|
|
return buf.Bytes()
|
|
}
|
|
|
|
var _ = Describe("Tar extraction fallback for link-less filesystems", func() {
|
|
It("downloads a layered image once and preserves whiteouts before copying links", func() {
|
|
base := buildLayer(
|
|
tar.Header{Name: "lib/removed.so", Mode: 0644, PAXRecords: map[string]string{"content": "removed"}},
|
|
tar.Header{Name: "lib/libcublas.so.12", Mode: 0644, PAXRecords: map[string]string{"content": "old library"}},
|
|
)
|
|
top := buildLayer(
|
|
tar.Header{Name: "lib/.wh.removed.so", Mode: 0644},
|
|
tar.Header{Name: "lib/libcublas.so.12", Mode: 0644, PAXRecords: map[string]string{"content": "new library"}},
|
|
tar.Header{Name: "lib/libcublas.so", Typeflag: tar.TypeSymlink, Linkname: "libcublas.so.12", Mode: 0777},
|
|
)
|
|
image, err := mutate.AppendLayers(empty.Image, base, top)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
tmp := GinkgoT().TempDir()
|
|
tarPath := filepath.Join(tmp, "rootfs.tar")
|
|
Expect(DownloadOCIImageTar(context.Background(), image, "test/image", tarPath, nil)).To(Succeed())
|
|
|
|
originalSymlink := symlink
|
|
symlink = func(string, string) error { return syscall.ENOTSUP }
|
|
DeferCleanup(func() { symlink = originalSymlink })
|
|
|
|
destination := filepath.Join(tmp, "destination")
|
|
Expect(os.Mkdir(destination, 0755)).To(Succeed())
|
|
Expect(ExtractOCIImageFromTar(context.Background(), tarPath, "test/image", destination, nil)).To(Succeed())
|
|
Expect(filepath.Join(destination, "lib", "removed.so")).NotTo(BeAnExistingFile())
|
|
Expect(os.ReadFile(filepath.Join(destination, "lib", "libcublas.so.12"))).To(Equal([]byte("new library")))
|
|
Expect(os.ReadFile(filepath.Join(destination, "lib", "libcublas.so"))).To(Equal([]byte("new library")))
|
|
})
|
|
|
|
Describe("isLinkUnsupportedError", func() {
|
|
It("recognises filesystem link-unsupported errors", func() {
|
|
Expect(isLinkUnsupportedError(syscall.ENOTSUP)).To(BeTrue())
|
|
Expect(isLinkUnsupportedError(syscall.EOPNOTSUPP)).To(BeTrue())
|
|
Expect(isLinkUnsupportedError(syscall.EPERM)).To(BeTrue())
|
|
Expect(isLinkUnsupportedError(&os.LinkError{
|
|
Op: "symlink",
|
|
Old: "libcublas.so.12",
|
|
New: "/backends/lib/libcublas.so",
|
|
Err: syscall.ENOTSUP,
|
|
})).To(BeTrue())
|
|
})
|
|
|
|
It("does not misclassify unrelated errors", func() {
|
|
Expect(isLinkUnsupportedError(os.ErrNotExist)).To(BeFalse())
|
|
Expect(isLinkUnsupportedError(syscall.ENOSPC)).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("safeJoin", func() {
|
|
It("keeps entries inside the root", func() {
|
|
root := "/tmp/extract-root"
|
|
p, err := safeJoin(root, "lib/libcublas.so")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(p).To(Equal(filepath.Join(root, "lib/libcublas.so")))
|
|
})
|
|
|
|
It("rejects path traversal entries", func() {
|
|
_, err := safeJoin("/tmp/extract-root", "../../etc/passwd")
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Describe("extractTarCopyingLinks", func() {
|
|
It("preserves symlinks when the filesystem supports them", func() {
|
|
dir := GinkgoT().TempDir()
|
|
Expect(extractTarCopyingLinks(bytes.NewReader(buildTar()), dir)).To(Succeed())
|
|
|
|
linkPath := filepath.Join(dir, "lib", "libcublas.so")
|
|
fi, err := os.Lstat(linkPath)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(fi.Mode() & os.ModeSymlink).NotTo(BeZero())
|
|
|
|
data, err := os.ReadFile(linkPath)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(data)).To(Equal("real library bytes"))
|
|
})
|
|
|
|
It("copies the target when symlink creation is unsupported", func() {
|
|
// Simulate a CIFS/SMB mount: symlink() reports ENOTSUP.
|
|
origSymlink := symlink
|
|
symlink = func(string, string) error { return syscall.ENOTSUP }
|
|
DeferCleanup(func() { symlink = origSymlink })
|
|
|
|
dir := GinkgoT().TempDir()
|
|
Expect(extractTarCopyingLinks(bytes.NewReader(buildTar()), dir)).To(Succeed())
|
|
|
|
linkPath := filepath.Join(dir, "lib", "libcublas.so")
|
|
fi, err := os.Lstat(linkPath)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
// The entry must now be a real, regular file (a copy), not a symlink.
|
|
Expect(fi.Mode() & os.ModeSymlink).To(BeZero())
|
|
Expect(fi.Mode().IsRegular()).To(BeTrue())
|
|
|
|
data, err := os.ReadFile(linkPath)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(data)).To(Equal("real library bytes"))
|
|
})
|
|
|
|
It("materialises chained symlinks regardless of archive order", func() {
|
|
origSymlink := symlink
|
|
symlink = func(string, string) error { return syscall.ENOTSUP }
|
|
DeferCleanup(func() { symlink = origSymlink })
|
|
|
|
dir := GinkgoT().TempDir()
|
|
Expect(extractTarCopyingLinks(bytes.NewReader(buildChainedLinkTar()), dir)).To(Succeed())
|
|
|
|
Expect(os.ReadFile(filepath.Join(dir, "lib", "libcublas.so"))).To(Equal([]byte("real library bytes")))
|
|
Expect(os.ReadFile(filepath.Join(dir, "lib", "libcublas.so.12"))).To(Equal([]byte("real library bytes")))
|
|
})
|
|
})
|
|
})
|