banger/internal/config/config_test.go
Thales Maciel 617008e8f1
config: normalize ssh_key_path — expand ~/, reject non-absolute
Bug: resolveSSHKeyPath returned a configured ssh_key_path verbatim.
That meant:

- ssh_key_path = "~/.ssh/id_ed25519" kept the literal "~" — downstream
  readers (internal/guest/ssh.go, internal/daemon/image_seed.go,
  internal/daemon/vm_authsync.go, internal/cli/ssh.go) do raw
  os.ReadFile on the path and fail at runtime with a path that looks
  fine but isn't.
- ssh_key_path = "id_ed25519" (relative) silently worked or didn't
  depending on the daemon's cwd — the daemon process's cwd is not
  the user's shell cwd, so behavior was non-obvious.

Fix: add normalizeSSHKeyPath() run over configured values. It:

  - expands "~/..." against $HOME
  - rejects bare "~" (ambiguous)
  - rejects "~user/..." (we don't do user-tilde)
  - rejects relative paths outright
  - returns filepath.Clean'd absolute paths

Tests cover the accepting case (home-anchored expansion) and every
rejection branch via a table-driven subtests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:34 -03:00

316 lines
9.2 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"banger/internal/paths"
)
func TestLoadDefaultsResolveFirecrackerAndGenerateSSHKey(t *testing.T) {
configDir := t.TempDir()
sshDir := t.TempDir()
binDir := t.TempDir()
firecrackerPath := filepath.Join(binDir, "firecracker")
if err := os.WriteFile(firecrackerPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write firecracker: %v", err)
}
t.Setenv("PATH", binDir)
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: sshDir})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.FirecrackerBin != firecrackerPath {
t.Fatalf("FirecrackerBin = %q, want %q", cfg.FirecrackerBin, firecrackerPath)
}
// Default key lives under SSHDir (state dir), NOT ConfigDir/ssh.
// ConfigDir/ssh gets scrubbed by ensureVMSSHClientConfig on every
// daemon Open, so regression-guard that the generator never picks
// that path again.
wantKey := filepath.Join(sshDir, "id_ed25519")
if cfg.SSHKeyPath != wantKey {
t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, wantKey)
}
for _, path := range []string{wantKey, wantKey + ".pub"} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("stat %s: %v", path, err)
}
}
legacyKey := filepath.Join(configDir, "ssh", "id_ed25519")
if _, err := os.Stat(legacyKey); err == nil {
t.Fatalf("key was also generated at legacy path %s; config.Load must not write under ConfigDir/ssh anymore", legacyKey)
}
if cfg.DefaultImageName != "debian-bookworm" {
t.Fatalf("DefaultImageName = %q, want debian-bookworm", cfg.DefaultImageName)
}
}
func TestLoadSSHKeyPathExpandsHomeAnchored(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
configDir := t.TempDir()
data := []byte("ssh_key_path = \"~/mykeys/id_ed25519\"\n")
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(homeDir, "mykeys", "id_ed25519")
if cfg.SSHKeyPath != want {
t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, want)
}
}
func TestLoadRejectsInvalidSSHKeyPath(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"relative bare", "id_ed25519", "must be absolute"},
{"relative with dot", "./keys/id_ed25519", "must be absolute"},
{"bare tilde", "~", "bare '~' is not supported"},
{"user-tilde", "~other/id_ed25519", "only '~/' is expanded"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
configDir := t.TempDir()
data := []byte("ssh_key_path = \"" + tc.raw + "\"\n")
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err == nil {
t.Fatalf("Load %q: want error containing %q", tc.raw, tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Load %q: error = %v, want contains %q", tc.raw, err, tc.want)
}
})
}
}
func TestLoadAppliesConfigOverrides(t *testing.T) {
configDir := t.TempDir()
data := []byte(`
log_level = "debug"
firecracker_bin = "/opt/firecracker"
ssh_key_path = "/tmp/custom-key"
default_image_name = "void"
auto_stop_stale_after = "1h"
stats_poll_interval = "15s"
bridge_name = "br-test"
bridge_ip = "10.0.0.1"
cidr = "25"
tap_pool_size = 8
default_dns = "9.9.9.9"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatalf("write config.toml: %v", err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "debug" {
t.Fatalf("LogLevel = %q", cfg.LogLevel)
}
if cfg.FirecrackerBin != "/opt/firecracker" {
t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
}
if cfg.SSHKeyPath != "/tmp/custom-key" {
t.Fatalf("SSHKeyPath = %q", cfg.SSHKeyPath)
}
if cfg.DefaultImageName != "void" {
t.Fatalf("DefaultImageName = %q", cfg.DefaultImageName)
}
if cfg.AutoStopStaleAfter != time.Hour {
t.Fatalf("AutoStopStaleAfter = %s", cfg.AutoStopStaleAfter)
}
if cfg.StatsPollInterval != 15*time.Second {
t.Fatalf("StatsPollInterval = %s", cfg.StatsPollInterval)
}
if cfg.BridgeName != "br-test" || cfg.BridgeIP != "10.0.0.1" || cfg.CIDR != "25" {
t.Fatalf("bridge config = %+v", cfg)
}
if cfg.TapPoolSize != 8 {
t.Fatalf("TapPoolSize = %d", cfg.TapPoolSize)
}
if cfg.DefaultDNS != "9.9.9.9" {
t.Fatalf("DefaultDNS = %q", cfg.DefaultDNS)
}
}
func TestLoadAppliesLogLevelEnvOverride(t *testing.T) {
t.Setenv("BANGER_LOG_LEVEL", "warn")
cfg, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "warn" {
t.Fatalf("LogLevel = %q, want warn", cfg.LogLevel)
}
}
func TestLoadAcceptsFileSyncEntries(t *testing.T) {
configDir := t.TempDir()
data := []byte(`
[[file_sync]]
host = "~/.aws"
guest = "~/.aws"
[[file_sync]]
host = "/etc/resolv.conf"
guest = "/root/.config/resolv.conf"
mode = "0644"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.FileSync) != 2 {
t.Fatalf("FileSync = %+v", cfg.FileSync)
}
if cfg.FileSync[0].Host != "~/.aws" || cfg.FileSync[0].Guest != "~/.aws" {
t.Fatalf("entry[0] = %+v", cfg.FileSync[0])
}
if cfg.FileSync[1].Mode != "0644" {
t.Fatalf("entry[1] mode = %q", cfg.FileSync[1].Mode)
}
}
func TestLoadRejectsInvalidFileSyncEntries(t *testing.T) {
cases := []struct {
name string
toml string
want string
}{
{
"empty host",
`[[file_sync]]` + "\n" + `host = ""` + "\n" + `guest = "~/foo"`,
"host path is required",
},
{
"empty guest",
`[[file_sync]]` + "\n" + `host = "~/foo"` + "\n" + `guest = ""`,
"guest path is required",
},
{
"relative host",
`[[file_sync]]` + "\n" + `host = "foo/bar"` + "\n" + `guest = "~/foo"`,
"must be absolute",
},
{
"guest outside /root",
`[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "/etc/resolv.conf"`,
"must be under /root or ~/",
},
{
"path traversal",
`[[file_sync]]` + "\n" + `host = "~/../secrets"` + "\n" + `guest = "~/secrets"`,
"'..' segments",
},
{
"tilde user",
`[[file_sync]]` + "\n" + `host = "~other/foo"` + "\n" + `guest = "~/foo"`,
"only '~/' is expanded",
},
{
"invalid mode",
`[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "~/x"` + "\n" + `mode = "rwx"`,
"must be octal",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(tc.toml+"\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err == nil {
t.Fatalf("Load: want error containing %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Load error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestLoadAcceptsVMDefaults(t *testing.T) {
configDir := t.TempDir()
data := []byte(`
[vm_defaults]
vcpu = 4
memory_mib = 4096
disk_size = "16G"
system_overlay_size = "12G"
`)
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VMDefaults.VCPUCount != 4 {
t.Errorf("VCPUCount = %d, want 4", cfg.VMDefaults.VCPUCount)
}
if cfg.VMDefaults.MemoryMiB != 4096 {
t.Errorf("MemoryMiB = %d, want 4096", cfg.VMDefaults.MemoryMiB)
}
if cfg.VMDefaults.WorkDiskSizeBytes != 16*1024*1024*1024 {
t.Errorf("WorkDiskSizeBytes = %d, want 16 GiB", cfg.VMDefaults.WorkDiskSizeBytes)
}
if cfg.VMDefaults.SystemOverlaySizeByte != 12*1024*1024*1024 {
t.Errorf("SystemOverlaySizeByte = %d, want 12 GiB", cfg.VMDefaults.SystemOverlaySizeByte)
}
}
func TestLoadEmptyVMDefaultsLeavesZeros(t *testing.T) {
// No [vm_defaults] block → cfg.VMDefaults is the zero value,
// which the resolver will map to auto or builtin.
cfg, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: t.TempDir()})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.VMDefaults.VCPUCount != 0 || cfg.VMDefaults.MemoryMiB != 0 {
t.Errorf("VMDefaults = %+v, want zeroed", cfg.VMDefaults)
}
}
func TestLoadRejectsNegativeVMDefaults(t *testing.T) {
cases := map[string]string{
"vcpu": `[vm_defaults]` + "\n" + `vcpu = -1`,
"memory": `[vm_defaults]` + "\n" + `memory_mib = -1`,
"disk_size": `[vm_defaults]` + "\n" + `disk_size = "banana"`,
"overlay": `[vm_defaults]` + "\n" + `system_overlay_size = "banana"`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(body+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}); err == nil {
t.Fatal("expected error")
}
})
}
}