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>
453 lines
15 KiB
Go
453 lines
15 KiB
Go
package daemon
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/paths"
|
|
)
|
|
|
|
// TestSameDirOrParentHandlesSymlinks guards against a drift where
|
|
// sameDirOrParent (the gate that protects a user key under the
|
|
// legacy dir from the cleanup scrub) compares lexical paths and
|
|
// misses symlink aliasing.
|
|
//
|
|
// Scenario: user configured ssh_key_path at a path that lands inside
|
|
// ConfigDir/ssh via a symlink (e.g. ConfigDir is itself symlinked,
|
|
// or the user maintains a symlink alias for their key tree). The
|
|
// gate must resolve both sides to the same physical location and
|
|
// refuse to scrub.
|
|
func TestSameDirOrParentHandlesSymlinks(t *testing.T) {
|
|
physical := t.TempDir()
|
|
realDir := filepath.Join(physical, "real-ssh")
|
|
if err := os.Mkdir(realDir, 0o700); err != nil {
|
|
t.Fatalf("Mkdir: %v", err)
|
|
}
|
|
realKey := filepath.Join(realDir, "id_ed25519")
|
|
if err := os.WriteFile(realKey, []byte("PRIVATE"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// A symlink that aliases the whole real-ssh directory. The user
|
|
// configured ssh_key_path via this alias, but sameDirOrParent is
|
|
// called with the canonical (realDir) legacyDir path.
|
|
aliasDir := filepath.Join(physical, "alias-ssh")
|
|
if err := os.Symlink(realDir, aliasDir); err != nil {
|
|
t.Skipf("symlink unsupported on this filesystem: %v", err)
|
|
}
|
|
aliasKey := filepath.Join(aliasDir, "id_ed25519")
|
|
|
|
if !sameDirOrParent(realDir, aliasKey) {
|
|
t.Fatalf("sameDirOrParent(%q, %q) = false; symlinked key was not recognised as inside the dir — cleanup would delete it", realDir, aliasKey)
|
|
}
|
|
|
|
// Reverse direction: dir provided as a symlink, key as canonical.
|
|
if !sameDirOrParent(aliasDir, realKey) {
|
|
t.Fatalf("sameDirOrParent(%q, %q) = false; reverse symlink direction also missed", aliasDir, realKey)
|
|
}
|
|
|
|
// Negative: a key in a completely unrelated directory must not
|
|
// be reported inside either spelling of the legacy dir.
|
|
outside := filepath.Join(t.TempDir(), "other", "id_ed25519")
|
|
if err := os.MkdirAll(filepath.Dir(outside), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(outside, []byte("UNRELATED"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
if sameDirOrParent(realDir, outside) {
|
|
t.Fatalf("sameDirOrParent(%q, %q) = true; unrelated dir incorrectly flagged as inside", realDir, outside)
|
|
}
|
|
}
|
|
|
|
// A user-configured ssh_key_path that happens to live under the
|
|
// legacy $ConfigDir/ssh directory must survive the pre-opt-in
|
|
// migration cleanup. The old code did os.RemoveAll on the whole
|
|
// directory, which nuked the key. Pin the narrower behavior so a
|
|
// future refactor can't re-broaden the scrub.
|
|
func TestSyncVMSSHClientConfigPreservesUserKeyInLegacyDir(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
configDir := filepath.Join(homeDir, ".config", "banger")
|
|
legacyDir := filepath.Join(configDir, "ssh")
|
|
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
userKey := filepath.Join(legacyDir, "id_ed25519")
|
|
if err := os.WriteFile(userKey, []byte("PRIVATE"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
// A stale ssh_config under the same dir from pre-opt-in era.
|
|
legacyConfig := filepath.Join(legacyDir, "ssh_config")
|
|
if err := os.WriteFile(legacyConfig, []byte("stale"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
layout := paths.Layout{
|
|
ConfigDir: configDir,
|
|
KnownHostsPath: filepath.Join(homeDir, ".local", "state", "banger", "ssh", "known_hosts"),
|
|
}
|
|
if err := syncVMSSHClientConfig(layout, userKey); err != nil {
|
|
t.Fatalf("syncVMSSHClientConfig: %v", err)
|
|
}
|
|
|
|
// The configured key must survive.
|
|
if _, err := os.Stat(userKey); err != nil {
|
|
t.Fatalf("user-configured key disappeared: %v", err)
|
|
}
|
|
// Enclosing directory must also survive (it contains the key).
|
|
if _, err := os.Stat(legacyDir); err != nil {
|
|
t.Fatalf("legacy dir removed despite containing the configured key: %v", err)
|
|
}
|
|
// The stale legacy ssh_config file can still be gone in this
|
|
// case — the user's key isn't ssh_config, so cleaning up the
|
|
// sibling file is fine. We don't assert either way, since the
|
|
// gate is "don't delete the user's key" not "always delete the
|
|
// sibling file."
|
|
}
|
|
|
|
// With ssh_key_path configured outside ConfigDir/ssh, the legacy
|
|
// migration step should scrub the old sibling file and then the
|
|
// (now-empty) directory — no os.RemoveAll on anything still in use.
|
|
func TestSyncVMSSHClientConfigNarrowsCleanupToLegacyFile(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
configDir := filepath.Join(homeDir, ".config", "banger")
|
|
legacyDir := filepath.Join(configDir, "ssh")
|
|
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
// Simulate the pre-opt-in leftover: just the ssh_config file.
|
|
legacyConfig := filepath.Join(legacyDir, "ssh_config")
|
|
if err := os.WriteFile(legacyConfig, []byte("stale"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// ssh_key_path lives in the state dir (the new default location).
|
|
stateDir := filepath.Join(homeDir, ".local", "state", "banger", "ssh")
|
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
userKey := filepath.Join(stateDir, "id_ed25519")
|
|
if err := os.WriteFile(userKey, []byte("PRIVATE"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
layout := paths.Layout{
|
|
ConfigDir: configDir,
|
|
KnownHostsPath: filepath.Join(homeDir, ".local", "state", "banger", "ssh", "known_hosts"),
|
|
}
|
|
if err := syncVMSSHClientConfig(layout, userKey); err != nil {
|
|
t.Fatalf("syncVMSSHClientConfig: %v", err)
|
|
}
|
|
|
|
// Legacy ssh_config file: gone.
|
|
if _, err := os.Stat(legacyConfig); !os.IsNotExist(err) {
|
|
t.Fatalf("legacy ssh_config survived cleanup: %v", err)
|
|
}
|
|
// Legacy dir: gone, since it was empty after the file removal.
|
|
if _, err := os.Stat(legacyDir); !os.IsNotExist(err) {
|
|
t.Fatalf("legacy dir survived cleanup when empty: %v", err)
|
|
}
|
|
// User's key: untouched.
|
|
if _, err := os.Stat(userKey); err != nil {
|
|
t.Fatalf("user key disappeared: %v", err)
|
|
}
|
|
}
|
|
|
|
// If the legacy dir contains UNEXPECTED files (not ssh_config, not
|
|
// the configured key), leave the dir alone. os.Remove on a non-
|
|
// empty dir errors with ENOTEMPTY, which we swallow. Regression
|
|
// guard so the cleanup can never escalate to recursive deletion.
|
|
func TestSyncVMSSHClientConfigLeavesUnexpectedLegacyContents(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
configDir := filepath.Join(homeDir, ".config", "banger")
|
|
legacyDir := filepath.Join(configDir, "ssh")
|
|
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
// A user-managed file we have no business removing.
|
|
userFile := filepath.Join(legacyDir, "my-other-thing")
|
|
if err := os.WriteFile(userFile, []byte("mine"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
layout := paths.Layout{
|
|
ConfigDir: configDir,
|
|
KnownHostsPath: filepath.Join(homeDir, ".local", "state", "banger", "ssh", "known_hosts"),
|
|
}
|
|
// ssh_key_path lives elsewhere; cleanup would otherwise proceed.
|
|
stateKey := filepath.Join(homeDir, ".local", "state", "banger", "ssh", "id_ed25519")
|
|
if err := os.MkdirAll(filepath.Dir(stateKey), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(stateKey, []byte("PRIVATE"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
if err := syncVMSSHClientConfig(layout, stateKey); err != nil {
|
|
t.Fatalf("syncVMSSHClientConfig: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(userFile); err != nil {
|
|
t.Fatalf("user-managed legacy-dir file disappeared: %v", err)
|
|
}
|
|
if _, err := os.Stat(legacyDir); err != nil {
|
|
t.Fatalf("legacy dir vanished despite non-empty contents: %v", err)
|
|
}
|
|
}
|
|
|
|
// Under the opt-in contract the daemon writes its own ssh_config file
|
|
// and never touches ~/.ssh/config on its own.
|
|
func TestSyncVMSSHClientConfigWritesBangerFileOnly(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
knownHostsPath := filepath.Join(homeDir, ".local", "state", "banger", "ssh", "known_hosts")
|
|
layout := paths.Layout{
|
|
ConfigDir: filepath.Join(homeDir, ".config", "banger"),
|
|
KnownHostsPath: knownHostsPath,
|
|
}
|
|
keyPath := filepath.Join(homeDir, ".config", "banger", "ssh", "id_ed25519")
|
|
|
|
if err := syncVMSSHClientConfig(layout, keyPath); err != nil {
|
|
t.Fatalf("syncVMSSHClientConfig: %v", err)
|
|
}
|
|
|
|
// Banger's own ssh_config file has the `Host *.vm` stanza.
|
|
bangerConfig, err := os.ReadFile(BangerSSHConfigPath(layout))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(banger ssh_config): %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
"Host *.vm",
|
|
"IdentityFile " + keyPath,
|
|
"UserKnownHostsFile " + knownHostsPath,
|
|
"StrictHostKeyChecking accept-new",
|
|
} {
|
|
if !strings.Contains(string(bangerConfig), want) {
|
|
t.Fatalf("banger ssh_config missing %q:\n%s", want, bangerConfig)
|
|
}
|
|
}
|
|
|
|
// ~/.ssh/config must NOT have been created or modified.
|
|
if _, err := os.Stat(filepath.Join(homeDir, ".ssh", "config")); !os.IsNotExist(err) {
|
|
t.Fatalf("~/.ssh/config should be untouched; stat err = %v", err)
|
|
}
|
|
|
|
// Regression: the legacy posture (strict no + /dev/null) must not
|
|
// reappear in the banger file.
|
|
for _, must := range []string{
|
|
"StrictHostKeyChecking no",
|
|
"UserKnownHostsFile /dev/null",
|
|
} {
|
|
if strings.Contains(string(bangerConfig), must) {
|
|
t.Fatalf("banger ssh_config leaked legacy posture %q:\n%s", must, bangerConfig)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallUserSSHIncludeAddsIncludeBlock(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")}
|
|
if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
// Write a fake banger ssh_config so Install has something to include.
|
|
if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(banger ssh_config): %v", err)
|
|
}
|
|
|
|
if err := InstallUserSSHInclude(layout); err != nil {
|
|
t.Fatalf("InstallUserSSHInclude: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "config"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(~/.ssh/config): %v", err)
|
|
}
|
|
want := "Include " + BangerSSHConfigPath(layout)
|
|
if !strings.Contains(string(got), want) {
|
|
t.Fatalf("user config missing %q:\n%s", want, got)
|
|
}
|
|
if !strings.Contains(string(got), bangerSSHIncludeBegin) {
|
|
t.Fatalf("user config missing begin marker:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestInstallUserSSHIncludeIsIdempotent(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")}
|
|
if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if err := InstallUserSSHInclude(layout); err != nil {
|
|
t.Fatalf("InstallUserSSHInclude (%d): %v", i, err)
|
|
}
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(homeDir, ".ssh", "config"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
if n := strings.Count(string(got), bangerSSHIncludeBegin); n != 1 {
|
|
t.Fatalf("begin markers = %d, want 1:\n%s", n, got)
|
|
}
|
|
}
|
|
|
|
func TestInstallUserSSHIncludeMigratesLegacyInlineBlock(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
layout := paths.Layout{ConfigDir: filepath.Join(homeDir, ".config", "banger")}
|
|
if err := os.MkdirAll(layout.ConfigDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(BangerSSHConfigPath(layout), []byte("Host *.vm\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
sshDir := filepath.Join(homeDir, ".ssh")
|
|
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll(.ssh): %v", err)
|
|
}
|
|
legacy := strings.Join([]string{
|
|
"ServerAliveInterval 120",
|
|
"",
|
|
vmSSHConfigIncludeBegin,
|
|
"Host *.vm",
|
|
" User root",
|
|
" IdentityFile /some/old/key",
|
|
vmSSHConfigIncludeEnd,
|
|
"",
|
|
"Host other",
|
|
" HostName 192.0.2.5",
|
|
"",
|
|
}, "\n")
|
|
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(legacy), 0o600); err != nil {
|
|
t.Fatalf("seed legacy config: %v", err)
|
|
}
|
|
|
|
if err := InstallUserSSHInclude(layout); err != nil {
|
|
t.Fatalf("InstallUserSSHInclude: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(sshDir, "config"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
gotStr := string(got)
|
|
// Legacy inline block must be gone.
|
|
if strings.Contains(gotStr, vmSSHConfigIncludeBegin) {
|
|
t.Fatalf("legacy inline block survived:\n%s", gotStr)
|
|
}
|
|
// New Include block must be present.
|
|
if !strings.Contains(gotStr, bangerSSHIncludeBegin) {
|
|
t.Fatalf("new include block missing:\n%s", gotStr)
|
|
}
|
|
// Unrelated stanzas must be preserved.
|
|
for _, want := range []string{"ServerAliveInterval 120", "Host other"} {
|
|
if !strings.Contains(gotStr, want) {
|
|
t.Fatalf("user config lost unrelated entry %q:\n%s", want, gotStr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUninstallUserSSHIncludeRemovesBothMarkerBlocks(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
sshDir := filepath.Join(homeDir, ".ssh")
|
|
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
seed := strings.Join([]string{
|
|
"Host keep",
|
|
" HostName 198.51.100.1",
|
|
"",
|
|
vmSSHConfigIncludeBegin,
|
|
"Host *.vm",
|
|
vmSSHConfigIncludeEnd,
|
|
"",
|
|
bangerSSHIncludeBegin,
|
|
"Include /tmp/banger-ssh-config",
|
|
bangerSSHIncludeEnd,
|
|
"",
|
|
}, "\n")
|
|
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(seed), 0o600); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
if err := UninstallUserSSHInclude(); err != nil {
|
|
t.Fatalf("UninstallUserSSHInclude: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(sshDir, "config"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
gotStr := string(got)
|
|
for _, banned := range []string{vmSSHConfigIncludeBegin, bangerSSHIncludeBegin} {
|
|
if strings.Contains(gotStr, banned) {
|
|
t.Fatalf("residue of %q:\n%s", banned, gotStr)
|
|
}
|
|
}
|
|
if !strings.Contains(gotStr, "Host keep") {
|
|
t.Fatalf("lost unrelated entry:\n%s", gotStr)
|
|
}
|
|
}
|
|
|
|
func TestUninstallUserSSHIncludeIsNoOpWhenMissing(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
if err := UninstallUserSSHInclude(); err != nil {
|
|
t.Fatalf("UninstallUserSSHInclude on missing file: %v", err)
|
|
}
|
|
// Still no ~/.ssh/config.
|
|
if _, err := os.Stat(filepath.Join(homeDir, ".ssh", "config")); !os.IsNotExist(err) {
|
|
t.Fatalf("~/.ssh/config unexpectedly created; stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserSSHIncludeInstalledDetectsBothMarkers(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
seed string
|
|
wantIn bool
|
|
}{
|
|
{"missing file", "", false},
|
|
{"unrelated only", "Host other\n HostName 1.2.3.4\n", false},
|
|
{"legacy marker", vmSSHConfigIncludeBegin + "\nHost *.vm\n" + vmSSHConfigIncludeEnd + "\n", true},
|
|
{"new marker", bangerSSHIncludeBegin + "\nInclude /tmp/banger\n" + bangerSSHIncludeEnd + "\n", true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
if tc.seed != "" {
|
|
if err := os.MkdirAll(filepath.Join(homeDir, ".ssh"), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(homeDir, ".ssh", "config"), []byte(tc.seed), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
}
|
|
got, err := UserSSHIncludeInstalled()
|
|
if err != nil {
|
|
t.Fatalf("UserSSHIncludeInstalled: %v", err)
|
|
}
|
|
if got != tc.wantIn {
|
|
t.Fatalf("got %v, want %v", got, tc.wantIn)
|
|
}
|
|
})
|
|
}
|
|
}
|