Skip to content

Commit db5b591

Browse files
authored
Merge pull request #64 from stacklok/xattrs
Add override_stat xattr support for virtiofs mounts
2 parents b6e791b + 3ba0672 commit db5b591

File tree

5 files changed

+261
-0
lines changed

5 files changed

+261
-0
lines changed

internal/xattr/walk.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build darwin || linux
5+
6+
package xattr
7+
8+
import (
9+
"fmt"
10+
"io/fs"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
)
15+
16+
// SetOverrideStatTree walks root and sets user.containers.override_stat
17+
// on every file and directory. Each entry's real mode (from Lstat) is
18+
// preserved in the xattr value. Symlinks are skipped — they cannot carry
19+
// user.* xattrs on Linux, and skipping them prevents setting xattrs
20+
// outside the mount boundary via symlink traversal.
21+
//
22+
// The root path is resolved via [filepath.EvalSymlinks] before walking,
23+
// and every visited entry is verified to remain under the resolved root.
24+
//
25+
// Errors on individual entries are logged at debug level and skipped.
26+
// Returns an error only if the root itself cannot be accessed.
27+
//
28+
// On platforms other than macOS and Linux a no-op stub is provided.
29+
func SetOverrideStatTree(root string, uid, gid int) error {
30+
if _, err := os.Lstat(root); err != nil {
31+
return fmt.Errorf("access root %s: %w", root, err)
32+
}
33+
34+
realRoot, err := filepath.EvalSymlinks(root)
35+
if err != nil {
36+
return fmt.Errorf("resolve root: %w", err)
37+
}
38+
realRoot = filepath.Clean(realRoot)
39+
rootPrefix := realRoot + string(filepath.Separator)
40+
41+
return filepath.WalkDir(realRoot, func(path string, d fs.DirEntry, err error) error {
42+
if err != nil {
43+
return nil // best-effort, skip inaccessible entries
44+
}
45+
// Skip symlinks: prevents setting xattrs outside mount boundary,
46+
// and Linux rejects user.* xattrs on symlinks anyway.
47+
if d.Type()&fs.ModeSymlink != 0 {
48+
return nil
49+
}
50+
// Boundary check: verify path stays under resolved root.
51+
cleanPath := filepath.Clean(path)
52+
if cleanPath != realRoot && !strings.HasPrefix(cleanPath, rootPrefix) {
53+
if d.IsDir() {
54+
return fs.SkipDir
55+
}
56+
return nil
57+
}
58+
info, err := d.Info()
59+
if err != nil {
60+
return nil
61+
}
62+
SetOverrideStat(path, uid, gid, info.Mode())
63+
return nil
64+
})
65+
}

internal/xattr/walk_other.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build !darwin && !linux
5+
6+
package xattr
7+
8+
// SetOverrideStatTree is a no-op on platforms without xattr support.
9+
func SetOverrideStatTree(_ string, _, _ int) error { return nil }

internal/xattr/walk_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build darwin || linux
5+
6+
package xattr
7+
8+
import (
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
"golang.org/x/sys/unix"
16+
)
17+
18+
func TestSetOverrideStatTree_NestedTree(t *testing.T) {
19+
t.Parallel()
20+
21+
root := t.TempDir()
22+
sub1 := filepath.Join(root, "a")
23+
sub2 := filepath.Join(root, "a", "b")
24+
require.NoError(t, os.MkdirAll(sub2, 0o755))
25+
26+
// Create a regular file — it should also get the xattr.
27+
filePath := filepath.Join(sub1, "file.txt")
28+
require.NoError(t, os.WriteFile(filePath, []byte("hi"), 0o644))
29+
30+
require.NoError(t, SetOverrideStatTree(root, 1000, 1000))
31+
32+
// All directories should have the xattr set.
33+
for _, dir := range []string{root, sub1, sub2} {
34+
val := readXattrOpt(t, dir)
35+
assert.Contains(t, val, "1000:1000:", "dir %s should have override xattr", dir)
36+
}
37+
38+
// Regular files should also have the xattr set.
39+
val := readXattrOpt(t, filePath)
40+
assert.Contains(t, val, "1000:1000:", "file should have override xattr")
41+
}
42+
43+
func TestSetOverrideStatTree_SymlinkToExternalDir(t *testing.T) {
44+
t.Parallel()
45+
46+
root := t.TempDir()
47+
external := t.TempDir()
48+
externalSub := filepath.Join(external, "secret")
49+
require.NoError(t, os.Mkdir(externalSub, 0o755))
50+
51+
// Create a symlink inside root pointing to an external directory.
52+
require.NoError(t, os.Symlink(external, filepath.Join(root, "escape")))
53+
54+
require.NoError(t, SetOverrideStatTree(root, 1000, 1000))
55+
56+
// The external directory must NOT have the xattr set.
57+
_, err := unix.Lgetxattr(external, overrideKey, make([]byte, 256))
58+
assert.Error(t, err, "external dir should not have override xattr")
59+
_, err = unix.Lgetxattr(externalSub, overrideKey, make([]byte, 256))
60+
assert.Error(t, err, "external subdir should not have override xattr")
61+
}
62+
63+
func TestSetOverrideStatTree_SymlinkToFile(t *testing.T) {
64+
t.Parallel()
65+
66+
root := t.TempDir()
67+
target := filepath.Join(root, "real.txt")
68+
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
69+
require.NoError(t, os.Symlink(target, filepath.Join(root, "link.txt")))
70+
71+
require.NoError(t, SetOverrideStatTree(root, 1000, 1000))
72+
73+
// The real file gets the xattr (it's a regular file under root).
74+
val := readXattrOpt(t, target)
75+
assert.Contains(t, val, "1000:1000:", "real file should have override xattr")
76+
77+
// The symlink itself should NOT have the xattr.
78+
link := filepath.Join(root, "link.txt")
79+
_, err := unix.Lgetxattr(link, overrideKey, make([]byte, 256))
80+
assert.Error(t, err, "symlink should not have override xattr")
81+
}
82+
83+
func TestSetOverrideStatTree_InaccessibleRoot(t *testing.T) {
84+
t.Parallel()
85+
86+
err := SetOverrideStatTree("/nonexistent/path/xattr-test", 1000, 1000)
87+
assert.Error(t, err, "should fail on inaccessible root")
88+
}
89+
90+
func TestSetOverrideStatTree_EmptyDir(t *testing.T) {
91+
t.Parallel()
92+
93+
root := t.TempDir()
94+
require.NoError(t, SetOverrideStatTree(root, 1000, 1000))
95+
96+
// Root dir itself should have the xattr.
97+
val := readXattrOpt(t, root)
98+
assert.Contains(t, val, "1000:1000:", "root dir should have override xattr")
99+
}
100+
101+
func TestSetOverrideStatTree_RootIsSymlink(t *testing.T) {
102+
t.Parallel()
103+
104+
real := t.TempDir()
105+
sub := filepath.Join(real, "child")
106+
require.NoError(t, os.Mkdir(sub, 0o755))
107+
108+
// Create a symlink that points to real. The walk should resolve it
109+
// and set xattrs on the real directory tree.
110+
link := filepath.Join(t.TempDir(), "link")
111+
require.NoError(t, os.Symlink(real, link))
112+
113+
require.NoError(t, SetOverrideStatTree(link, 1000, 1000))
114+
115+
val := readXattrOpt(t, real)
116+
assert.Contains(t, val, "1000:1000:", "resolved root should have override xattr")
117+
val = readXattrOpt(t, sub)
118+
assert.Contains(t, val, "1000:1000:", "child dir should have override xattr")
119+
}
120+
121+
func TestSetOverrideStatTree_DifferentUIDGID(t *testing.T) {
122+
t.Parallel()
123+
124+
root := t.TempDir()
125+
filePath := filepath.Join(root, "file.txt")
126+
require.NoError(t, os.WriteFile(filePath, []byte("data"), 0o644))
127+
128+
// Use different UID and GID to verify both are written independently.
129+
require.NoError(t, SetOverrideStatTree(root, 1000, 2000))
130+
131+
val := readXattrOpt(t, root)
132+
assert.Contains(t, val, "1000:2000:", "dir should have uid=1000 gid=2000")
133+
134+
val = readXattrOpt(t, filePath)
135+
assert.Contains(t, val, "1000:2000:", "file should have uid=1000 gid=2000")
136+
}
137+
138+
// readXattrOpt reads the override_stat xattr and returns its value, or
139+
// empty string if the xattr is not set.
140+
func readXattrOpt(t *testing.T, path string) string {
141+
t.Helper()
142+
buf := make([]byte, 256)
143+
n, err := unix.Lgetxattr(path, overrideKey, buf)
144+
if err != nil {
145+
return ""
146+
}
147+
return string(buf[:n])
148+
}

microvm.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/stacklok/go-microvm/hypervisor"
3838
"github.com/stacklok/go-microvm/hypervisor/libkrun"
3939
"github.com/stacklok/go-microvm/image"
40+
"github.com/stacklok/go-microvm/internal/xattr"
4041
"github.com/stacklok/go-microvm/net/firewall"
4142
"github.com/stacklok/go-microvm/net/hosted"
4243
rootfspkg "github.com/stacklok/go-microvm/rootfs"
@@ -235,6 +236,32 @@ func Run(ctx context.Context, imageRef string, opts ...Option) (*VM, error) {
235236
span.End()
236237
}
237238

239+
// 5b. Validate and set override_stat xattrs on virtiofs mount entries so
240+
// the guest sees correct ownership (macOS + Linux; no-op on other platforms).
241+
for _, m := range cfg.virtioFS {
242+
if m.OverrideUID < 0 || m.OverrideGID < 0 {
243+
return nil, fmt.Errorf("virtiofs mount %q: OverrideUID/OverrideGID must be non-negative", m.Tag)
244+
}
245+
if m.OverrideUID == 0 && m.OverrideGID > 0 {
246+
return nil, fmt.Errorf("virtiofs mount %q: OverrideGID set without OverrideUID", m.Tag)
247+
}
248+
}
249+
_, xattrSpan := tracer.Start(ctx, "microvm.VirtioFSOverrideStat")
250+
for _, m := range cfg.virtioFS {
251+
if m.OverrideUID > 0 && !m.ReadOnly {
252+
gid := m.OverrideGID
253+
if gid <= 0 {
254+
gid = m.OverrideUID
255+
}
256+
if err := xattr.SetOverrideStatTree(m.HostPath, m.OverrideUID, gid); err != nil {
257+
xattrSpan.RecordError(err)
258+
slog.Warn("failed to set override_stat on virtiofs mount",
259+
"tag", m.Tag, "path", m.HostPath, "error", err)
260+
}
261+
}
262+
}
263+
xattrSpan.End()
264+
238265
// 6. Start VM via backend.
239266
_, vmSpawnSpan := tracer.Start(ctx, "microvm.VMSpawn")
240267
slog.Debug("starting VM")

options.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ type VirtioFSMount struct {
4747
// support host-side read-only virtiofs. A compromised guest kernel
4848
// could bypass this restriction.
4949
ReadOnly bool
50+
// OverrideUID, when > 0, causes go-microvm to set the
51+
// user.containers.override_stat xattr on every file and directory under
52+
// HostPath before the VM starts. This makes libkrun's virtiofs FUSE
53+
// server report the given UID/GID to the guest instead of the real
54+
// host values. Symlinks are skipped for safety.
55+
// A zero value means "no override." Since 0 is the zero value for int,
56+
// overriding to UID 0 (root) is not supported through this field.
57+
// Ignored for ReadOnly mounts.
58+
OverrideUID int
59+
// OverrideGID sets the group ID for the override_stat xattr.
60+
// When 0 and OverrideUID > 0, defaults to OverrideUID.
61+
OverrideGID int
5062
}
5163

5264
// EgressPolicy restricts outbound VM traffic to specific DNS hostnames.

0 commit comments

Comments
 (0)