A VM name flows into five places that all have narrower grammars
than "arbitrary string":
- the guest's /etc/hostname (vm_disk.patchRootOverlay)
- the guest's /etc/hosts (same)
- the <name>.vm DNS record (vmdns.RecordName)
- the kernel command line (system.BuildBootArgs*)
- VM-dir file-path fragments (layout.VMsDir/<id>, etc.)
Nothing in the chain was validating the input. A name with
whitespace, newline, dot, slash, colon, or = would produce broken
hostnames, weird DNS labels, smuggled kernel cmdline tokens, or
(in the worst case) surprising traversal through the on-disk
layout. Not host shell injection — we already avoid shelling out
with the raw name — but a real correctness and supportability bug.
New: model.ValidateVMName. Rules:
- 1..63 chars (DNS label max per RFC 1123; also a comfortable
/etc/hostname cap)
- lowercase ASCII letters, digits, '-' only
- no leading or trailing '-'
- no normalization — the name is the user-visible identifier
(store key, `ssh <name>.vm`, `vm show`); silently rewriting
"MyVM" → "myvm" would hand the user back something different
than they typed
Called from two places:
- internal/cli/commands_vm.go vmCreateParamsFromFlags — rejects
bad `--name` values before any RPC. Empty name still passes
through so the daemon can generate one.
- internal/daemon/vm_create.go reserveVM — defense in depth for
any non-CLI RPC caller (SDK, direct JSON over the socket).
Tests:
- internal/model/vm_name_test.go — exhaustive character-class
matrix (space, newline, tab, dot, slash, colon, equals, quote,
control chars, unicode letters, uppercase, leading/trailing
hyphen, over-length, max-length-exact, digits-only).
- internal/cli TestVMCreateParamsFromFlagsRejectsInvalidName —
CLI wire-through + empty-name passthrough.
- internal/daemon TestReserveVMRejectsInvalidName — daemon
defense-in-depth (including `box/../evil` path-traversal).
- scripts/smoke.sh — end-to-end rejection + no-leaked-row
assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
4.3 KiB
Go
125 lines
4.3 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
)
|
|
|
|
// TestReserveVMAllowsNameThatPrefixesExistingVM is a regression for a
|
|
// correctness bug in the name-uniqueness check: reserveVM used to
|
|
// route through FindVM, which falls back to prefix-matching on both
|
|
// ids and names. That meant a perfectly valid new name like "beta"
|
|
// could be rejected simply because an existing VM's id or name
|
|
// started with "beta". Exact-name lookup via store.GetVMByName fixes
|
|
// it. The test seeds a VM whose id and name are long strings, then
|
|
// tries to reserve a new VM with a name that's a prefix of each —
|
|
// both must succeed.
|
|
func TestReserveVMAllowsNameThatPrefixesExistingVM(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmp := t.TempDir()
|
|
d := &Daemon{
|
|
store: openDaemonStore(t),
|
|
layout: paths.Layout{VMsDir: filepath.Join(tmp, "vms"), RuntimeDir: filepath.Join(tmp, "runtime")},
|
|
config: model.DaemonConfig{BridgeIP: model.DefaultBridgeIP},
|
|
}
|
|
wireServices(d)
|
|
|
|
existing := testVM("longname-sandbox-foobar", "image-x", "172.16.0.50")
|
|
upsertDaemonVM(t, ctx, d.store, existing)
|
|
|
|
image := testImage("image-x")
|
|
image.ID = "image-x"
|
|
image.Name = "image-x"
|
|
if err := d.store.UpsertImage(ctx, image); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
|
|
// New VM name is a prefix of the existing id (which is
|
|
// "longname-sandbox-foobar-id" per testVM). Old FindVM-based check
|
|
// would reject this.
|
|
if vm, err := d.vm.reserveVM(ctx, "longname", image, model.VMSpec{VCPUCount: 1, MemoryMiB: 128}); err != nil {
|
|
t.Fatalf("reserveVM(prefix of id): %v", err)
|
|
} else if vm.Name != "longname" {
|
|
t.Fatalf("reserveVM returned name=%q, want longname", vm.Name)
|
|
}
|
|
|
|
// Prefix of the existing name ("longname-sandbox") must also work.
|
|
if vm, err := d.vm.reserveVM(ctx, "longname-sandbox", image, model.VMSpec{VCPUCount: 1, MemoryMiB: 128}); err != nil {
|
|
t.Fatalf("reserveVM(prefix of name): %v", err)
|
|
} else if vm.Name != "longname-sandbox" {
|
|
t.Fatalf("reserveVM returned name=%q, want longname-sandbox", vm.Name)
|
|
}
|
|
}
|
|
|
|
// TestReserveVMRejectsExactDuplicateName confirms the uniqueness
|
|
// check still catches actual collisions after the FindVM → GetVMByName
|
|
// switch.
|
|
func TestReserveVMRejectsExactDuplicateName(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmp := t.TempDir()
|
|
d := &Daemon{
|
|
store: openDaemonStore(t),
|
|
layout: paths.Layout{VMsDir: filepath.Join(tmp, "vms"), RuntimeDir: filepath.Join(tmp, "runtime")},
|
|
config: model.DaemonConfig{BridgeIP: model.DefaultBridgeIP},
|
|
}
|
|
wireServices(d)
|
|
existing := testVM("sandbox", "image-x", "172.16.0.51")
|
|
upsertDaemonVM(t, ctx, d.store, existing)
|
|
|
|
image := testImage("image-x")
|
|
image.ID = "image-x"
|
|
image.Name = "image-x"
|
|
if err := d.store.UpsertImage(ctx, image); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
|
|
_, err := d.vm.reserveVM(ctx, "sandbox", image, model.VMSpec{VCPUCount: 1, MemoryMiB: 128})
|
|
if err == nil {
|
|
t.Fatal("reserveVM with duplicate name should have failed")
|
|
}
|
|
if !strings.Contains(err.Error(), "already exists") {
|
|
t.Fatalf("err = %v, want 'already exists'", err)
|
|
}
|
|
}
|
|
|
|
// TestReserveVMRejectsInvalidName pins defense-in-depth: the CLI
|
|
// already validates, but any other RPC caller (banger SDK, direct
|
|
// JSON over the socket) lands here without going through the CLI.
|
|
// The name ends up in /etc/hostname, kernel boot args, DNS records,
|
|
// and file paths — the daemon must refuse anything that isn't a
|
|
// valid DNS label.
|
|
func TestReserveVMRejectsInvalidName(t *testing.T) {
|
|
ctx := context.Background()
|
|
tmp := t.TempDir()
|
|
d := &Daemon{
|
|
store: openDaemonStore(t),
|
|
layout: paths.Layout{VMsDir: filepath.Join(tmp, "vms"), RuntimeDir: filepath.Join(tmp, "runtime")},
|
|
config: model.DaemonConfig{BridgeIP: model.DefaultBridgeIP},
|
|
}
|
|
wireServices(d)
|
|
|
|
image := testImage("image-x")
|
|
image.ID = "image-x"
|
|
image.Name = "image-x"
|
|
if err := d.store.UpsertImage(ctx, image); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
|
|
for _, bad := range []string{
|
|
"MyBox", // uppercase
|
|
"my box", // space
|
|
"my.box", // dot
|
|
"box\n", // newline
|
|
"-box", // leading hyphen
|
|
"box/../evil", // path separator + traversal
|
|
} {
|
|
if _, err := d.vm.reserveVM(ctx, bad, image, model.VMSpec{VCPUCount: 1, MemoryMiB: 128}); err == nil {
|
|
t.Fatalf("reserveVM(%q) = nil error, want rejection", bad)
|
|
}
|
|
}
|
|
}
|