Skip to content

Commit 27636ef

Browse files
JAORMXclaude
andcommitted
Add ReadOnly support for VirtioFS mounts
Plumb a ReadOnly flag through the full VirtioFSMount chain so callers can declare host directories as read-only inside the guest. Enforcement is guest-side via MS_RDONLY mount flags; libkrun does not yet support host-side read-only virtiofs. Closes #53 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7c27de6 commit 27636ef

18 files changed

Lines changed: 202 additions & 20 deletions

File tree

guest/boot/boot.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
6464
cfg.workspaceUID,
6565
cfg.workspaceGID,
6666
cfg.mountRetries,
67+
cfg.workspaceReadOnly,
6768
); err != nil {
6869
logger.Warn("workspace mount failed, continuing without workspace", "error", err)
6970
}

guest/boot/boot_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func TestDefaultConfig(t *testing.T) {
2626
assert.Equal(t, "workspace", cfg.workspaceTag)
2727
assert.Equal(t, 1000, cfg.workspaceUID)
2828
assert.Equal(t, 1000, cfg.workspaceGID)
29+
assert.False(t, cfg.workspaceReadOnly)
2930
assert.Equal(t, 5, cfg.mountRetries)
3031
assert.Equal(t, 22, cfg.sshPort)
3132
assert.Equal(t, "/home/sandbox/.ssh/authorized_keys", cfg.sshKeysPath)
@@ -38,6 +39,18 @@ func TestDefaultConfig(t *testing.T) {
3839
assert.True(t, cfg.lockdownRoot)
3940
}
4041

42+
func TestWithWorkspaceReadOnly(t *testing.T) {
43+
t.Parallel()
44+
cfg := defaultConfig()
45+
assert.False(t, cfg.workspaceReadOnly)
46+
47+
WithWorkspaceReadOnly(true).apply(cfg)
48+
assert.True(t, cfg.workspaceReadOnly)
49+
50+
WithWorkspaceReadOnly(false).apply(cfg)
51+
assert.False(t, cfg.workspaceReadOnly)
52+
}
53+
4154
func TestWithWorkspace(t *testing.T) {
4255
t.Parallel()
4356
cfg := defaultConfig()

guest/boot/options.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type config struct {
2020
workspaceTag string
2121
workspaceUID int
2222
workspaceGID int
23+
workspaceReadOnly bool
2324
mountRetries int
2425
sshPort int
2526
sshKeysPath string
@@ -66,6 +67,12 @@ func WithWorkspace(mountPoint, tag string, uid, gid int) Option {
6667
})
6768
}
6869

70+
// WithWorkspaceReadOnly makes the workspace virtiofs mount read-only inside the
71+
// guest. The mount is performed with MS_RDONLY so guest processes cannot write.
72+
func WithWorkspaceReadOnly(readOnly bool) Option {
73+
return optionFunc(func(c *config) { c.workspaceReadOnly = readOnly })
74+
}
75+
6976
// WithMountRetries sets the maximum number of retries for workspace mount.
7077
func WithMountRetries(n int) Option {
7178
return optionFunc(func(c *config) { c.mountRetries = n })

guest/mount/mount.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,32 @@ func Essential(logger *slog.Logger, tmpSizeMiB uint32) error {
8888

8989
// Workspace mounts a virtiofs share at the given mount point, retrying up to
9090
// maxRetries times to allow the host to expose the filesystem. On success the
91-
// mount point is chowned to uid:gid.
92-
func Workspace(logger *slog.Logger, mountPoint, tag string, uid, gid, maxRetries int) error {
91+
// mount point is chowned to uid:gid. When readOnly is true the mount is
92+
// performed with MS_RDONLY so the guest cannot write to it.
93+
func Workspace(logger *slog.Logger, mountPoint, tag string, uid, gid, maxRetries int, readOnly bool) error {
9394
if err := os.MkdirAll(mountPoint, 0o755); err != nil {
9495
return fmt.Errorf("creating workspace mount point %s: %w", mountPoint, err)
9596
}
9697

98+
flags := uintptr(syscall.MS_NOSUID | syscall.MS_NODEV)
99+
if readOnly {
100+
flags |= syscall.MS_RDONLY
101+
}
102+
97103
var lastErr error
98104
for i := range maxRetries {
99-
lastErr = syscall.Mount(tag, mountPoint, "virtiofs", syscall.MS_NOSUID|syscall.MS_NODEV, "")
105+
lastErr = syscall.Mount(tag, mountPoint, "virtiofs", flags, "")
100106
if lastErr == nil {
101-
if err := os.Chown(mountPoint, uid, gid); err != nil {
102-
return fmt.Errorf("chown workspace %s: %w", mountPoint, err)
107+
// Skip chown on read-only mounts: chown returns EROFS on a
108+
// filesystem mounted with MS_RDONLY. Ownership is cosmetic
109+
// anyway since the mount prevents writes regardless.
110+
if !readOnly {
111+
if err := os.Chown(mountPoint, uid, gid); err != nil {
112+
// Clean up the mount so we don't leave a mounted
113+
// filesystem that the caller thinks failed.
114+
_ = syscall.Unmount(mountPoint, 0)
115+
return fmt.Errorf("chown workspace %s: %w", mountPoint, err)
116+
}
103117
}
104118
return nil
105119
}

guest/mount/mount_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestWorkspaceReturnsErrorForInvalidMount(t *testing.T) {
2727
if os.Getuid() == 0 {
2828
t.Skip("test must run as non-root")
2929
}
30-
err := Workspace(slog.Default(), t.TempDir()+"/ws", "nonexistent-tag", 1000, 1000, 1)
30+
err := Workspace(slog.Default(), t.TempDir()+"/ws", "nonexistent-tag", 1000, 1000, 1, false)
3131
assert.Error(t, err)
3232
}
3333

guest/vmconfig/vmconfig.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ type Config struct {
1919
// TmpSizeMiB is the size of the /tmp tmpfs in MiB. Zero means use the
2020
// mount package default (256 MiB).
2121
TmpSizeMiB uint32 `json:"tmp_size_mib,omitempty"`
22+
// VirtioFSMounts describes virtiofs mounts the host configured for the VM.
23+
// The guest init uses this to determine mount-time options (e.g. read-only).
24+
VirtioFSMounts []VirtioFSMountInfo `json:"virtiofs_mounts,omitempty"`
25+
}
26+
27+
// VirtioFSMountInfo carries mount metadata from the host to the guest init.
28+
type VirtioFSMountInfo struct {
29+
// Tag is the virtiofs tag identifying this mount.
30+
Tag string `json:"tag"`
31+
// ReadOnly indicates the mount should be read-only inside the guest.
32+
ReadOnly bool `json:"read_only,omitempty"`
2233
}
2334

2435
// Read loads the VM config from /etc/go-microvm.json.

guest/vmconfig/vmconfig_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ func TestReadFrom(t *testing.T) {
3131
content: strPtr(`{"tmp_size_mib":512}`),
3232
want: Config{TmpSizeMiB: 512},
3333
},
34+
{
35+
name: "virtiofs mounts with read-only",
36+
content: strPtr(`{"virtiofs_mounts":[{"tag":"workspace","read_only":true},{"tag":"data"}]}`),
37+
want: Config{VirtioFSMounts: []VirtioFSMountInfo{
38+
{Tag: "workspace", ReadOnly: true},
39+
{Tag: "data"},
40+
}},
41+
},
3442
{
3543
name: "empty JSON object",
3644
content: strPtr(`{}`),

hooks/hooks_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ func TestInjectVMConfig(t *testing.T) {
5959
name: "zero-value config",
6060
cfg: vmconfig.Config{},
6161
},
62+
{
63+
name: "config with read-only mounts",
64+
cfg: vmconfig.Config{
65+
VirtioFSMounts: []vmconfig.VirtioFSMountInfo{
66+
{Tag: "workspace", ReadOnly: true},
67+
},
68+
},
69+
},
6270
}
6371

6472
for _, tt := range tests {

hypervisor/backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,5 @@ type PortForward struct {
9090
type FilesystemMount struct {
9191
Tag string
9292
HostPath string
93+
ReadOnly bool
9394
}

hypervisor/libkrun/backend.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func toRunnerPortForwards(ports []hypervisor.PortForward) []runner.PortForward {
187187
func toRunnerVirtioFS(mounts []hypervisor.FilesystemMount) []runner.VirtioFSMount {
188188
out := make([]runner.VirtioFSMount, len(mounts))
189189
for i, m := range mounts {
190-
out[i] = runner.VirtioFSMount{Tag: m.Tag, HostPath: m.HostPath}
190+
out[i] = runner.VirtioFSMount{Tag: m.Tag, HostPath: m.HostPath, ReadOnly: m.ReadOnly}
191191
}
192192
return out
193193
}

0 commit comments

Comments
 (0)