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) } } forbiddenKey := filepath.Join(configDir, "ssh", "id_ed25519") if _, err := os.Stat(forbiddenKey); err == nil { t.Fatalf("key was also generated at %s; config.Load must not write under ConfigDir/ssh", forbiddenKey) } 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 TestLoadDaemonDoesNotGenerateDefaultSSHKey(t *testing.T) { ownerHome := t.TempDir() sshDir := filepath.Join(t.TempDir(), "daemon-ssh") cfg, err := LoadDaemon(paths.Layout{ConfigDir: t.TempDir(), SSHDir: sshDir}, ownerHome) if err != nil { t.Fatalf("LoadDaemon: %v", err) } wantKey := filepath.Join(sshDir, "id_ed25519") if cfg.SSHKeyPath != wantKey { t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, wantKey) } if cfg.HostHomeDir != ownerHome { t.Fatalf("HostHomeDir = %q, want %q", cfg.HostHomeDir, ownerHome) } if _, err := os.Stat(wantKey); !os.IsNotExist(err) { t.Fatalf("LoadDaemon created %s, want no key material on daemon config load", wantKey) } } // TestLoadNormalizesAbsoluteSSHKeyPath pins filepath.Clean behaviour // for configured paths: trailing slashes and duplicate slashes are // flattened so downstream path comparisons 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) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) configDir := t.TempDir() hostsFile := filepath.Join(homeDir, ".config", "gh", "hosts.yml") data := []byte(` [[file_sync]] host = "~/.aws" guest = "~/.aws" [[file_sync]] host = "` + hostsFile + `" guest = "/root/.config/gh/hosts.yml" 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].Host != hostsFile || cfg.FileSync[1].Guest != "/root/.config/gh/hosts.yml" { t.Fatalf("entry[1] = %+v", cfg.FileSync[1]) } if cfg.FileSync[1].Mode != "0644" { t.Fatalf("entry[1] mode = %q", cfg.FileSync[1].Mode) } } func TestLoadDaemonAcceptsFileSyncPathUnderOwnerHome(t *testing.T) { ownerHome := t.TempDir() t.Setenv("HOME", t.TempDir()) configDir := t.TempDir() allowed := filepath.Join(ownerHome, ".config", "gh", "hosts.yml") data := []byte(` [[file_sync]] host = "` + allowed + `" guest = "~/.config/gh/hosts.yml" `) if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil { t.Fatal(err) } cfg, err := LoadDaemon(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}, ownerHome) if err != nil { t.Fatalf("LoadDaemon: %v", err) } got, err := ResolveFileSyncHostPath(cfg.FileSync[0].Host, cfg.HostHomeDir) if err != nil { t.Fatalf("ResolveFileSyncHostPath: %v", err) } if got != allowed { t.Fatalf("resolved host path = %q, want %q", got, allowed) } } 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 TestLoadRejectsFileSyncHostOutsideHome(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) configDir := t.TempDir() data := []byte(` [[file_sync]] host = "/etc/resolv.conf" guest = "~/resolv.conf" `) if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil { t.Fatal(err) } _, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}) if err == nil { t.Fatal("Load: want error for host path outside home") } if !strings.Contains(err.Error(), "owner home") { t.Fatalf("Load error = %v, want owner-home diagnostic", err) } } func TestLoadDaemonRejectsFileSyncHostOutsideOwnerHome(t *testing.T) { ownerHome := t.TempDir() t.Setenv("HOME", t.TempDir()) configDir := t.TempDir() outside := filepath.Join(t.TempDir(), "secret.txt") data := []byte(` [[file_sync]] host = "` + outside + `" guest = "~/secret.txt" `) if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil { t.Fatal(err) } _, err := LoadDaemon(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}, ownerHome) if err == nil { t.Fatal("LoadDaemon: want error for host path outside owner home") } if !strings.Contains(err.Error(), "owner home") { t.Fatalf("LoadDaemon error = %v, want owner-home diagnostic", err) } } 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") } }) } }