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>
This commit is contained in:
parent
b2756f5e7e
commit
cef9bf92a5
3 changed files with 155 additions and 5 deletions
|
|
@ -70,6 +70,86 @@ func TestLoadSSHKeyPathExpandsHomeAnchored(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue