The symlink test in this commit catches a real bug: sameDirOrParent used filepath.Abs for both sides of the "is the key inside the legacy dir?" check, but filepath.Abs doesn't resolve symlinks. A user whose ssh_key_path pointed into ConfigDir/ssh via a symlinked spelling (e.g. ConfigDir itself is a symlink, or the user maintains an alias tree) would have their key silently deleted by the legacy-dir scrub — the gate thought the key lived elsewhere because the two spellings didn't match lexically. Fix: resolvePathForComparison tries filepath.EvalSymlinks first, falls back to filepath.Abs when the path doesn't exist yet (new install, pre-first-Open). Both sides of the sameDirOrParent comparison now use this helper, so a symlinked key + canonical dir (or the reverse) lands in the same physical path before the Rel check. Tests added in this commit: internal/daemon/ssh_client_config_test.go TestSameDirOrParentHandlesSymlinks — symlinked-key + canonical-dir and the reverse are both reported "inside"; unrelated paths stay out. Skips if the filesystem doesn't support symlinks. internal/config/config_test.go TestLoadNormalizesAbsoluteSSHKeyPath — trailing slash, duplicate slashes, dot segments all collapse via filepath.Clean, so two spellings of the same path compare equal downstream. TestEnsureDefaultSSHKeyRejectsCorruptExistingFile — regression guard against a future "regenerate if invalid" patch that would silently nuke a real user key. TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir — pins the absolute-path guard that stops a bad layout from scribbling into cwd (this was the test that caught the stray internal/config/ssh/ a few commits back). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
396 lines
12 KiB
Go
396 lines
12 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)
|
|
}
|
|
}
|
|
|
|
// TestLoadNormalizesAbsoluteSSHKeyPath pins filepath.Clean behaviour
|
|
// for configured paths: trailing slashes and duplicate slashes are
|
|
// flattened so downstream comparisons (e.g. sameDirOrParent) don't
|
|
// see two spellings for the same path.
|
|
func TestLoadNormalizesAbsoluteSSHKeyPath(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
}{
|
|
{"trailing slash collapsed", "/tmp/keys/id_ed25519/", "/tmp/keys/id_ed25519"},
|
|
{"duplicate slashes collapsed", "/tmp//keys///id_ed25519", "/tmp/keys/id_ed25519"},
|
|
{"dot segments resolved", "/tmp/keys/./id_ed25519", "/tmp/keys/id_ed25519"},
|
|
}
|
|
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)
|
|
}
|
|
cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
|
|
if err != nil {
|
|
t.Fatalf("Load %q: %v", tc.raw, err)
|
|
}
|
|
if cfg.SSHKeyPath != tc.want {
|
|
t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEnsureDefaultSSHKeyRejectsCorruptExistingFile pins the
|
|
// "don't silently overwrite" contract: if someone wrote garbage to
|
|
// the default key path (or the key was truncated mid-write by a
|
|
// previous crash), config.Load must surface the parse error instead
|
|
// of pretending the file is usable. The regression we care about is
|
|
// a future refactor that adds "regenerate if invalid" silently —
|
|
// that would nuke a real user key on every daemon Open.
|
|
func TestEnsureDefaultSSHKeyRejectsCorruptExistingFile(t *testing.T) {
|
|
sshDir := t.TempDir()
|
|
corruptKey := filepath.Join(sshDir, "id_ed25519")
|
|
if err := os.WriteFile(corruptKey, []byte("not a pem private key"), 0o600); err != nil {
|
|
t.Fatalf("write corrupt key: %v", err)
|
|
}
|
|
|
|
_, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: sshDir})
|
|
if err == nil {
|
|
t.Fatal("Load: want error when existing key file is not a valid private key")
|
|
}
|
|
// The error should mention the parse failure, not "regenerated".
|
|
if strings.Contains(err.Error(), "regenerat") {
|
|
t.Fatalf("Load silently regenerated: %v", err)
|
|
}
|
|
// Original garbage must still be there — the invariant is "don't
|
|
// touch files you can't parse".
|
|
data, readErr := os.ReadFile(corruptKey)
|
|
if readErr != nil {
|
|
t.Fatalf("ReadFile: %v", readErr)
|
|
}
|
|
if string(data) != "not a pem private key" {
|
|
t.Fatalf("key content = %q, want the original garbage", string(data))
|
|
}
|
|
}
|
|
|
|
// TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir pins the
|
|
// guard in resolveSSHKeyPath: if a caller builds a layout without
|
|
// SSHDir and StateDir, they shouldn't get a key generated in cwd.
|
|
// The guard existed before (added after a test scribbled into
|
|
// internal/config/ssh/); this test prevents it from going away.
|
|
func TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir(t *testing.T) {
|
|
_, err := Load(paths.Layout{ConfigDir: t.TempDir()})
|
|
if err == nil {
|
|
t.Fatal("Load: want error when neither SSHDir nor StateDir is set")
|
|
}
|
|
if !strings.Contains(err.Error(), "must be absolute") {
|
|
t.Fatalf("Load error = %v, want 'must be absolute' diagnostic", err)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|