Add overlay symlink resolution using ReadLinkFS

Signed-off-by: Derek McGowan <derek@mcg.dev>
This commit is contained in:
Derek McGowan committed 2026-04-04 22:37:39 -07:00
1 parent 21d666cfbc
commit 30951c6f03
4 files changed
+458 -24

No files matched your search

+35 -15
View File
@@ -48,6 +48,29 @@ func (v view) Close() error {
return nil
}
// readLinkView wraps an fs.ReadLinkFS with a cleanup function,
// implementing both View and fs.ReadLinkFS.
type readLinkView struct {
fs.ReadLinkFS
cleanup func() error
}
func (v readLinkView) Close() error {
if v.cleanup != nil {
return v.cleanup()
}
return nil
}
// newView creates a View that preserves fs.ReadLinkFS if the underlying
// fs.FS implements it.
func newView(fsys fs.FS, cleanup func() error) View {
if rl, ok := fsys.(fs.ReadLinkFS); ok {
return readLinkView{ReadLinkFS: rl, cleanup: cleanup}
}
return view{FS: fsys, cleanup: cleanup}
}
// FSMounts returns a View for the provided mounts if possible to open
// the mounts directly without mounting.
//
@@ -89,7 +112,7 @@ func openBind(m mount.Mount) (View, error) {
if err != nil {
return nil, err
}
return view{FS: r.FS(), cleanup: r.Close}, nil
return newView(r.FS(), r.Close), nil
}
func openOverlay(m mount.Mount) (View, error) {
@@ -157,16 +180,13 @@ func newOverlayView(layers []View) (View, error) {
return nil, err
}
return view{
FS: ofs,
cleanup: func() error {
var errs []error
for _, layer := range layers {
errs = append(errs, layer.Close())
}
return errors.Join(errs...)
},
}, nil
return newView(ofs, func() error {
var errs []error
for _, layer := range layers {
errs = append(errs, layer.Close())
}
return errors.Join(errs...)
}), nil
}
// resolveOverlayValue resolves a single overlay option value, which may be
@@ -177,7 +197,7 @@ func resolveOverlayValue(s string, preceding []mount.Mount) ([]View, error) {
if err != nil {
return nil, err
}
return []View{view{FS: r.FS(), cleanup: r.Close}}, nil
return []View{newView(r.FS(), r.Close)}, nil
}
tmplExpr, suffix := splitTemplateSuffix(s)
@@ -200,7 +220,7 @@ func resolveOverlayValue(s string, preceding []mount.Mount) ([]View, error) {
if err != nil {
return "", fmt.Errorf("failed to open source of mount %d: %w", i, err)
}
addLayer(view{FS: r.FS(), cleanup: r.Close})
addLayer(newView(r.FS(), r.Close))
return "", nil
},
"mount": func(i int) (string, error) {
@@ -259,7 +279,7 @@ func resolveOverlayValue(s string, preceding []mount.Mount) ([]View, error) {
}
return nil, fmt.Errorf("failed to create sub view for path %q: %w", suffix, err)
}
layers[i] = view{FS: subFS, cleanup: l.Close}
layers[i] = newView(subFS, l.Close)
}
}
@@ -301,7 +321,7 @@ func openOverlayPaths(options []string) ([]View, error) {
}
return nil, err
}
layers = append(layers, view{FS: r.FS(), cleanup: r.Close})
layers = append(layers, newView(r.FS(), r.Close))
}
return layers, nil
}
+235 -6
View File
@@ -22,6 +22,8 @@ import (
"io/fs"
"path"
"sort"
"strings"
"syscall"
)
// OverlayOpaqueXattrs are the xattr names used to indicate an opaque directory.
@@ -33,6 +35,10 @@ var OverlayOpaqueXattrs = []string{
"user.overlay.opaque",
}
// maxSymlinks is the maximum number of symlinks that will be followed
// when resolving a path, to prevent infinite loops.
const maxSymlinks = 255
// NewOverlayFS returns a new fs.FS that overlays the provided layers.
// The layers should be provided in order from upper to lower.
func NewOverlayFS(layers []fs.FS) (fs.FS, error) {
@@ -63,10 +69,239 @@ func hasOpaqueParent(layer fs.FS, name string) bool {
return false
}
// lstatLayer returns the FileInfo for name in the layer without following
// the final symlink component. If the layer implements fs.ReadLinkFS, it
// uses Lstat directly. Otherwise it falls back to Open+Stat which follows
// symlinks (degraded behavior).
func lstatLayer(layer fs.FS, name string) (fs.FileInfo, error) {
if rl, ok := layer.(fs.ReadLinkFS); ok {
return rl.Lstat(name)
}
f, err := layer.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return f.Stat()
}
// readlinkLayer returns the symlink target for name in the layer.
// The layer must implement fs.ReadLinkFS.
func readlinkLayer(layer fs.FS, name string) (string, error) {
if rl, ok := layer.(fs.ReadLinkFS); ok {
return rl.ReadLink(name)
}
return "", &fs.PathError{Op: "readlink", Path: name, Err: syscall.EINVAL}
}
func (o *overlayFS) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
return o.openFollow(name, 0)
}
// Lstat returns a FileInfo describing the named file without following
// the final symlink component. Intermediate symlinks are resolved through
// the overlay so that cross-layer symlink targets are found correctly.
func (o *overlayFS) Lstat(name string) (fs.FileInfo, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrInvalid}
}
resolved, err := o.resolve(name, false, 0)
if err != nil {
return nil, err
}
return o.lstatDirect(resolved)
}
// ReadLink returns the destination of the named symbolic link.
// Intermediate path components are resolved through the overlay.
func (o *overlayFS) ReadLink(name string) (string, error) {
if !fs.ValidPath(name) {
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid}
}
resolved, err := o.resolve(name, false, 0)
if err != nil {
return "", err
}
return o.readlinkDirect(resolved)
}
// openFollow opens a file, resolving symlinks at the overlay level.
// depth tracks the number of symlinks followed to detect loops.
func (o *overlayFS) openFollow(name string, depth int) (fs.File, error) {
resolved, err := o.resolve(name, true, depth)
if err != nil {
return nil, err
}
return o.openDirect(resolved)
}
// resolve walks the path component by component, resolving symlinks at the
// overlay level. When follow is true, symlinks in the final component are
// also resolved. Returns the fully resolved path with no symlinks.
func (o *overlayFS) resolve(name string, follow bool, depth int) (string, error) {
if name == "." {
return ".", nil
}
parts := strings.Split(name, "/")
resolved := ""
for i, part := range parts {
isLast := i == len(parts)-1
var candidate string
if resolved == "" {
candidate = part
} else {
candidate = resolved + "/" + part
}
// Lstat this component across the overlay layers
fi, err := o.lstatDirect(candidate)
if err != nil {
return "", err
}
if fi.Mode()&fs.ModeSymlink != 0 {
if isLast && !follow {
// Don't follow the final component for Lstat/ReadLink
resolved = candidate
continue
}
depth++
if depth > maxSymlinks {
return "", &fs.PathError{Op: "open", Path: name, Err: syscall.ELOOP}
}
target, err := o.readlinkDirect(candidate)
if err != nil {
return "", err
}
// Build the new path: target + remaining components
remaining := ""
if !isLast {
remaining = strings.Join(parts[i+1:], "/")
}
var newPath string
if path.IsAbs(target) {
// Absolute symlink: resolve from root
target = strings.TrimPrefix(target, "/")
if remaining != "" {
newPath = target + "/" + remaining
} else {
newPath = target
}
} else {
// Relative symlink: resolve from parent of current component
parent := path.Dir(candidate)
joined := path.Join(parent, target)
if remaining != "" {
newPath = joined + "/" + remaining
} else {
newPath = joined
}
}
newPath = path.Clean(newPath)
if newPath == "." {
return ".", nil
}
// Restart resolution from the root of the overlay
return o.resolve(newPath, follow, depth)
}
if !fi.IsDir() && !isLast {
// Non-directory, non-symlink in an intermediate position
return "", &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
resolved = candidate
}
return resolved, nil
}
// lstatDirect does an Lstat across overlay layers, respecting whiteouts
// and opaque directories. It does NOT resolve symlinks - the path must
// already have intermediate symlinks resolved.
func (o *overlayFS) lstatDirect(name string) (fs.FileInfo, error) {
var firstErr error
var opaque bool
for _, layer := range o.layers {
if opaque {
break
}
if hasOpaqueParent(layer, name) {
opaque = true
}
fi, err := lstatLayer(layer, name)
if err != nil {
var pe *fs.PathError
if !errors.As(err, &pe) && firstErr == nil {
firstErr = err
}
continue
}
if isWhiteout(fi) {
return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist}
}
return fi, nil
}
if firstErr != nil {
return nil, firstErr
}
return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist}
}
// readlinkDirect reads the symlink target from the first matching layer,
// respecting whiteouts and opaque directories. The path must already have
// intermediate symlinks resolved.
func (o *overlayFS) readlinkDirect(name string) (string, error) {
var opaque bool
for _, layer := range o.layers {
if opaque {
break
}
if hasOpaqueParent(layer, name) {
opaque = true
}
fi, err := lstatLayer(layer, name)
if err != nil {
continue
}
if isWhiteout(fi) {
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist}
}
if fi.Mode()&fs.ModeSymlink == 0 {
return "", &fs.PathError{Op: "readlink", Path: name, Err: syscall.EINVAL}
}
return readlinkLayer(layer, name)
}
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist}
}
// openDirect opens a fully-resolved path (no symlinks) using the
// original layer-by-layer logic with directory merging.
func (o *overlayFS) openDirect(name string) (fs.File, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
var dirLayers []fs.FS
var firstErr error
@@ -197,12 +432,6 @@ func (d *overlayDir) ReadDir(n int) ([]fs.DirEntry, error) {
d.entries = append(d.entries, e)
}
}
// Opaque check is done during Open to filter layers, so we don't need to check here?
// Wait, Open filters layers for *this* directory.
// But subdirectories?
// No, d.layers are the layers for *this* directory.
// The opaque check in Open ensured that if a layer was opaque, we stopped adding lower layers to d.layers.
// So we can safely merge all d.layers.
}
sort.Slice(d.entries, func(i, j int) bool { return d.entries[i].Name() < d.entries[j].Name() })
d.read = true
+176
View File
@@ -128,6 +128,182 @@ func TestOverlayFS(t *testing.T) {
// TestOverlayFSDirReplacedByFile tests that when an upper layer replaces a
// directory with a regular file, child paths return ErrNotExist rather than
// a confusing "not a directory" error.
// TestOverlayFSCrossLayerSymlink tests that a symlink in a lower layer
// correctly resolves to a file in an upper layer when the symlink target
// has been replaced by the upper layer.
func TestOverlayFSCrossLayerSymlink(t *testing.T) {
base := t.TempDir()
upper := filepath.Join(base, "upper")
lower := filepath.Join(base, "lower")
// Lower layer: has a symlink etc/config -> ../opt/config
// and the original target file
if err := os.MkdirAll(filepath.Join(lower, "etc"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(lower, "opt"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../opt/config", filepath.Join(lower, "etc", "config")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(lower, "opt", "config"), []byte("old-config"), 0o644); err != nil {
t.Fatal(err)
}
// Upper layer: replaces opt/config with new content
if err := os.MkdirAll(filepath.Join(upper, "opt"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(upper, "opt", "config"), []byte("new-config"), 0o644); err != nil {
t.Fatal(err)
}
lowerdir := strings.Join([]string{upper, lower}, ":")
v, err := fsview.FSMounts([]mount.Mount{
{
Type: "overlay",
Source: "overlay",
Options: []string{
"lowerdir=" + lowerdir,
},
},
})
if err != nil {
t.Fatalf("FSMounts failed: %v", err)
}
defer v.Close()
// Opening etc/config should follow the symlink through the overlay
// and find opt/config from the upper layer (new-config), not the lower.
data, err := fs.ReadFile(v, "etc/config")
if err != nil {
t.Fatalf("ReadFile etc/config: %v", err)
}
if string(data) != "new-config" {
t.Errorf("expected new-config, got %s", data)
}
// Direct open of opt/config should also return upper layer content
data, err = fs.ReadFile(v, "opt/config")
if err != nil {
t.Fatalf("ReadFile opt/config: %v", err)
}
if string(data) != "new-config" {
t.Errorf("expected new-config, got %s", data)
}
}
// TestOverlayFSAbsoluteSymlinkCrossLayer tests absolute symlinks that
// resolve across layers.
func TestOverlayFSAbsoluteSymlinkCrossLayer(t *testing.T) {
base := t.TempDir()
upper := filepath.Join(base, "upper")
lower := filepath.Join(base, "lower")
// Lower layer: has /etc/group -> /nix/store/abcd/group
// and the original target file
if err := os.MkdirAll(filepath.Join(lower, "etc"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(lower, "nix", "store", "abcd"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/nix/store/abcd/group", filepath.Join(lower, "etc", "group")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(lower, "nix", "store", "abcd", "group"), []byte("old-group"), 0o644); err != nil {
t.Fatal(err)
}
// Upper layer: replaces nix/store/abcd/group with new content
if err := os.MkdirAll(filepath.Join(upper, "nix", "store", "abcd"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(upper, "nix", "store", "abcd", "group"), []byte("new-group"), 0o644); err != nil {
t.Fatal(err)
}
lowerdir := strings.Join([]string{upper, lower}, ":")
v, err := fsview.FSMounts([]mount.Mount{
{
Type: "overlay",
Source: "overlay",
Options: []string{
"lowerdir=" + lowerdir,
},
},
})
if err != nil {
t.Fatalf("FSMounts failed: %v", err)
}
defer v.Close()
// Opening etc/group should follow the absolute symlink through the overlay
// and find nix/store/abcd/group from the upper layer.
data, err := fs.ReadFile(v, "etc/group")
if err != nil {
t.Fatalf("ReadFile etc/group: %v", err)
}
if string(data) != "new-group" {
t.Errorf("expected new-group, got %s", data)
}
}
// TestOverlayFSSymlinkChain tests chained symlinks across layers.
func TestOverlayFSSymlinkChain(t *testing.T) {
base := t.TempDir()
upper := filepath.Join(base, "upper")
lower := filepath.Join(base, "lower")
// Lower layer: a -> b (symlink), b -> c (symlink), c is a file
if err := os.MkdirAll(lower, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(upper, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("b", filepath.Join(lower, "a")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("c", filepath.Join(lower, "b")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(lower, "c"), []byte("lower-c"), 0o644); err != nil {
t.Fatal(err)
}
// Upper layer: replaces c
if err := os.WriteFile(filepath.Join(upper, "c"), []byte("upper-c"), 0o644); err != nil {
t.Fatal(err)
}
lowerdir := strings.Join([]string{upper, lower}, ":")
v, err := fsview.FSMounts([]mount.Mount{
{
Type: "overlay",
Source: "overlay",
Options: []string{
"lowerdir=" + lowerdir,
},
},
})
if err != nil {
t.Fatalf("FSMounts failed: %v", err)
}
defer v.Close()
// Opening a should follow a -> b -> c through the overlay,
// finding c from the upper layer.
data, err := fs.ReadFile(v, "a")
if err != nil {
t.Fatalf("ReadFile a: %v", err)
}
if string(data) != "upper-c" {
t.Errorf("expected upper-c, got %s", data)
}
}
func TestOverlayFSDirReplacedByFile(t *testing.T) {
base := t.TempDir()
layer1 := filepath.Join(base, "layer1")
+12 -3
View File
@@ -18,6 +18,7 @@ package erofs
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
@@ -81,14 +82,22 @@ func handleMount(m mount.Mount) (fsview.View, error) {
return nil, err
}
rlfs, ok := efs.(fs.ReadLinkFS)
if !ok {
for _, c := range closers {
c.Close()
}
return nil, fmt.Errorf("erofs: filesystem does not implement fs.ReadLinkFS: %w", errdefs.ErrNotImplemented)
}
return &erofsView{
FS: efs,
closers: closers,
ReadLinkFS: rlfs,
closers: closers,
}, nil
}
type erofsView struct {
fs.FS
fs.ReadLinkFS
closers []io.Closer
}