Skip to content

Commit bbb1125

Browse files
smoserclaude
andcommitted
erofs: make ls mount-less and cross-platform via layered fs.FS
`apko erofs ls` now opens each EROFS layer blob directly with go-erofs and walks a layered fs.FS in user space, instead of mounting the layers and walking the merged mountpoint. This removes the kernel/FUSE dependency for `ls` (works on darwin/windows too), eliminates the mount log noise, and is faster. Introduces a reusable pkg/erofsmount.Stack: a layered fs.FS implementing fs.ReadDirFS/StatFS/ReadLinkFS with full AUFS-style overlay semantics — .wh.NAME whiteouts hide siblings, .wh..wh..opq markers hide all lower- layer entries in a directory, ancestor whiteouts hide whole subtrees, type-mismatch in a higher layer shadows lower contents. apko's writer never emits whiteouts (it splits one rootfs into groups, doesn't merge), so 15 unit tests synthesize the whiteout cases via testing/fstest.MapFS. Mount and Unmount remain Linux-only since they genuinely need the kernel or FUSE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3af8dec commit bbb1125

9 files changed

Lines changed: 1060 additions & 272 deletions

File tree

pkg/erofsmount/ls.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright 2026 Chainguard, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package erofsmount
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"io"
21+
"io/fs"
22+
"strings"
23+
"text/tabwriter"
24+
25+
"github.com/chainguard-dev/clog"
26+
)
27+
28+
// Ls produces a `tar tvf`-style listing of every entry in src. It opens each
29+
// EROFS layer blob directly via go-erofs, presents the layers as a single
30+
// merged view via Stack, walks that view, and prints each entry to w.
31+
//
32+
// Ls does not mount anything and is cross-platform — it works wherever
33+
// go-erofs builds, regardless of kernel features.
34+
//
35+
// The opts.Mode, opts.Arch, and opts.ReadOnly fields are inherited from the
36+
// Mount API for shape parity; only Arch is meaningful here (used to pick a
37+
// manifest from a multi-arch OCI index).
38+
func Ls(ctx context.Context, src Source, opts Options, w io.Writer) error {
39+
log := clog.FromContext(ctx)
40+
41+
layers, cleanup, err := OpenLayers(src, opts.Arch)
42+
if err != nil {
43+
return fmt.Errorf("open layers: %w", err)
44+
}
45+
defer func() {
46+
if cerr := cleanup(); cerr != nil {
47+
log.Warnf("close layer blobs: %v", cerr)
48+
}
49+
}()
50+
51+
stack := NewStack(layers...)
52+
return walkAndPrint(ctx, stack, w)
53+
}
54+
55+
// walkAndPrint walks fsys and writes one line per entry to w in a format
56+
// similar to `tar tvf`: mode uid/gid size yyyy-mm-dd hh:mm relpath[ -> target].
57+
func walkAndPrint(ctx context.Context, fsys fs.FS, w io.Writer) error {
58+
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
59+
err := fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, walkErr error) error {
60+
if cerr := ctx.Err(); cerr != nil {
61+
return cerr
62+
}
63+
if walkErr != nil {
64+
return walkErr
65+
}
66+
if name == "." {
67+
return nil
68+
}
69+
info, err := lstatOn(fsys, name)
70+
if err != nil {
71+
return err
72+
}
73+
line := formatEntry(fsys, info, name)
74+
if _, werr := fmt.Fprintln(tw, line); werr != nil {
75+
return werr
76+
}
77+
return nil
78+
})
79+
if err != nil {
80+
return err
81+
}
82+
return tw.Flush()
83+
}
84+
85+
// formatEntry renders one entry. uid/gid are pulled from go-erofs's accessor
86+
// interfaces on info.Sys(); they're zero for entries from filesystems that
87+
// don't expose them. Symlink targets come from fs.ReadLinkFS when fsys
88+
// implements it.
89+
func formatEntry(fsys fs.FS, info fs.FileInfo, name string) string {
90+
uid, gid := uidGidFromSys(info.Sys())
91+
size := info.Size()
92+
mt := info.ModTime().UTC().Format("2006-01-02 15:04")
93+
94+
suffix := ""
95+
if info.Mode()&fs.ModeSymlink != 0 {
96+
if rl, ok := fsys.(fs.ReadLinkFS); ok {
97+
if t, err := rl.ReadLink(name); err == nil {
98+
suffix = " -> " + t
99+
}
100+
}
101+
}
102+
103+
return fmt.Sprintf("%s\t%d/%d\t%d\t%s\t%s%s",
104+
formatMode(info.Mode()), uid, gid, size, mt, name, suffix)
105+
}
106+
107+
// uidGidFromSys extracts numeric ownership from info.Sys() via the
108+
// single-method accessor interfaces that go-erofs documents on its Stat
109+
// type. Anything else (including nil) yields (0, 0).
110+
func uidGidFromSys(sys any) (uint32, uint32) {
111+
if sys == nil {
112+
return 0, 0
113+
}
114+
type uider interface{ UID() uint32 }
115+
type gider interface{ GID() uint32 }
116+
var uid, gid uint32
117+
if u, ok := sys.(uider); ok {
118+
uid = u.UID()
119+
}
120+
if g, ok := sys.(gider); ok {
121+
gid = g.GID()
122+
}
123+
return uid, gid
124+
}
125+
126+
// formatMode renders a 10-character mode string in the style of `ls -l`.
127+
func formatMode(mode fs.FileMode) string {
128+
var b strings.Builder
129+
b.Grow(10)
130+
switch {
131+
case mode.IsDir():
132+
b.WriteByte('d')
133+
case mode&fs.ModeSymlink != 0:
134+
b.WriteByte('l')
135+
case mode&fs.ModeNamedPipe != 0:
136+
b.WriteByte('p')
137+
case mode&fs.ModeSocket != 0:
138+
b.WriteByte('s')
139+
case mode&fs.ModeCharDevice != 0:
140+
b.WriteByte('c')
141+
case mode&fs.ModeDevice != 0:
142+
b.WriteByte('b')
143+
default:
144+
b.WriteByte('-')
145+
}
146+
perm := mode.Perm()
147+
for i, ch := range "rwxrwxrwx" {
148+
if perm&(1<<(8-i)) != 0 {
149+
b.WriteByte(byte(ch))
150+
} else {
151+
b.WriteByte('-')
152+
}
153+
}
154+
return b.String()
155+
}

pkg/erofsmount/ls_linux.go

Lines changed: 0 additions & 159 deletions
This file was deleted.

0 commit comments

Comments
 (0)