banger/internal/daemon/ssh_client_config_test.go
Thales Maciel cef9bf92a5
ssh-config: harden sameDirOrParent against symlinks + add edge tests
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>
2026-04-22 17:48:06 -03:00

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)
}
})
}
}