Skip to content

fix(backends): copy links when the filesystem rejects symlinks (CIFS/SMB) (#10890)#10893

Closed
localai-bot wants to merge 4 commits into
masterfrom
fix/oci-symlink-cifs-10890
Closed

fix(backends): copy links when the filesystem rejects symlinks (CIFS/SMB) (#10890)#10893
localai-bot wants to merge 4 commits into
masterfrom
fix/oci-symlink-cifs-10890

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

Description

Fixes #10890.

Backend installation on a CIFS/SMB-backed /backends volume fails for every backend with:

failed to extract image tar: symlink libcublas.so.12.8.5.5 /backends/cuda12-llama-cpp.install-tmp/lib/libcublas.so: operation not supported

The OCI image tar is extracted via containerd's archive.Apply, which creates symlinks with os.Symlink. CIFS/SMB mounts (a common way to store /backends on a NAS) do not support symlinks and reject the syscall with operation not supported, aborting the whole install and leaving an empty backend directory. The CUDA llama.cpp image trips on the libcublas.so -> libcublas.so.12.x symlink.

Fix

When archive.Apply fails with a link-unsupported error (ENOTSUP/EOPNOTSUPP/EPERM, or the matching "operation not supported/permitted" text), the staging directory is reset and re-extracted with a pure-Go tar walker that:

  • still attempts real symlinks/hardlinks first, so link semantics are preserved wherever the filesystem allows them;
  • degrades an unsupported symlink/hardlink into a copy of the target file's contents in place, so the backend files remain usable;
  • defers link copies to a second pass so a link appearing before its target in the tar still resolves;
  • guards every entry against path traversal.

mutate.Extract already flattens the layers before this point, so the tar carries no whiteouts to interpret; a plain walker is sufficient. The primary archive.Apply path is unchanged, so this only affects filesystems that were already failing 100% of installs. The staging directory is the ephemeral, per-install *.install-tmp dir, so wiping it before the fallback pass is safe.

Tests

Added pkg/oci/extract_internal_test.go (Ginkgo/Gomega):

  • isLinkUnsupportedError truth table (syscall errnos + wrapped *os.LinkError + negatives).
  • safeJoin keeps entries in-root and rejects traversal.
  • extractTarCopyingLinks preserves symlinks on a symlink-capable FS, and, with symlink() stubbed to return ENOTSUP, materialises the target as a real regular file copy with the right bytes.

Note

Built and verified via CI (no Go toolchain in the authoring sandbox).

Assisted-by: Claude:opus-4.8 [Claude Code]

… 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]
Comment thread pkg/oci/image.go Fixed
Comment thread pkg/oci/image.go Fixed
Comment thread pkg/oci/image.go Fixed
Comment thread pkg/oci/image.go Fixed
Comment thread pkg/oci/image.go Fixed
Comment thread pkg/oci/image.go Fixed
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:opus-4.8 [Claude Code]
Comment thread pkg/oci/image.go Fixed
mudler added 2 commits July 17, 2026 18:39
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]
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]
@mudler

mudler commented Jul 17, 2026

Copy link
Copy Markdown
Owner

@localai-bot this is not right, OCI images have layers and this as-is its not unpacking layers.

@mudler

mudler commented Jul 20, 2026

Copy link
Copy Markdown
Owner

@localai-org-maint-bot to you to pick it up

@localai-org-maint-bot

Copy link
Copy Markdown
Collaborator

I picked this up and confirmed the current fallback is not safe to merge as-is. Besides bypassing OCI layer application, DownloadOCIImageTar currently appends the downloaded layers to the original image, which duplicates the layer list and reopens the source during extraction.

I prepared a replacement locally that:

  • keeps mutate.Extract as the primary flattened-layer path;
  • on link-unsupported targets, rewinds and applies that flattened tar with containerd archive.Apply into a link-capable staging directory, preserving whiteout/layer semantics;
  • copies the staged tree to the target, resolving symlinks within the staging root only when the target filesystem rejects links;
  • builds the downloaded image from empty.Image plus downloaded layers, so the original source is not reopened.

Focused fallback tests pass (6/6), all pkg/oci tests pass (15/15), go vet ./pkg/oci and golangci-lint pass, and the full e2e suite passed 94/94. I have not pushed the commit because the mandatory local pre-commit coverage ratchet failed at 40.4% versus the committed 48.5% baseline after the test suite passed; I will not bypass or weaken that gate. @mudler this PR still needs the layer-aware replacement before merge; the staged patch is preserved in the isolated worktree while the coverage-gate discrepancy is resolved.

@mudler mudler closed this Jul 21, 2026
@localai-org-maint-bot

Copy link
Copy Markdown
Collaborator

@mudler update: the layer-aware replacement is rebased cleanly onto current master 2b61e4b and remains staged in the isolated worktree. Fresh verification passes: the layered-image and whiteout regression, all 16 pkg/oci specs, go vet ./pkg/oci, golangci-lint with 0 issues, all 58 ./core suites, and e2e 94/94. The mandatory coverage hook is still the only blocker: all suites pass, but this host aggregates 41.2% against the current 54.2% baseline and exits nonzero; the master workflow for 2b61e4b is still queued. I have not bypassed the hook or lowered the baseline, and have not pushed an unverified commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot install any backend: operation not supported

4 participants