mirror of
https://github.com/containerd/containerd.git
synced 2026-11-06 05:17:02 +00:00
Add overlay symlink resolution using ReadLinkFS
Signed-off-by: Derek McGowan <derek@mcg.dev>
This commit is contained in:
4 files changed
+458
-24
No files matched your search
+35
-15
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user