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