Files
LocalAI/pkg/oci/extract_internal_test.go
localai-org-maint-bot 68a0460681 fix(oci): install backends on filesystems without symlinks (#11166)
* 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>
2026-07-30 16:23:42 +02:00

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")))
})
})
})