Files
mesh-catalog/modules/ssh-client/cmd/ssh-client-tools/sweep_test.go
T
mesh-tools acf075fa63
mesh/merge-gate head acf075fa63cc merged into trunk 9675babf528b — pass: builds ssh-client → ace, g14, novox, shanks; no bus step; every machine composes w…
mesh/repo-check head acf075fa63cc merged into trunk 9675babf528b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415)
ssh-client owns ~/.ssh, but treated every file it did not place as found, so a
predecessor's drop-in and old copies stayed there for weeks. Its process now
sweeps at start and every ten minutes: keys at any depth (and links to them),
authorized_keys, authorized_keys2 and sockets stay, and a folder holding one is
gone into; everything else moves to ~/.local/state/ssh-client/retired/<UTC
time>/, a folder never shared with another sweep, with a list beside it, which
ssh_client_check shows. Nothing is deleted.
2026-10-17 12:38:16 +00:00

272 lines
9.1 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"syscall"
"testing"
"time"
)
// ADR 0415, issue 1017: the sweep keeps ~/.ssh to what this module knows and moves the rest out.
func sweepHome(t *testing.T) (*Client, string) {
t.Helper()
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
put := func(rel, body string, mode os.FileMode) {
p := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), mode); err != nil {
t.Fatal(err)
}
}
key := "-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE\n"
for rel := range Declared {
if rel != "config.d" {
put(rel, "declared\n", 0o600)
}
}
put("id_ed25519", key, 0o600)
put("id_ed25519.pub", ed25519Line(1, "me")+"\n", 0o644)
put("work_key", key, 0o600) // a key with an unusual name
put("work_key.pub", ed25519Line(2, "work")+"\n", 0o644)
put("work_key-cert.pub", "ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29t work\n", 0o644)
put("config.bak-20260807-015327", "old\n", 0o600)
put("known_hosts.old", "old\n", 0o644)
put("config.d/mesh", "Host ace\n", 0o600)
put("removed-hal-20261002/config", "old\n", 0o600)
put("removed-drone-20261004/id_drone", key, 0o600) // a key inside a leftover folder
put("removed-drone-20261004/config", "old\n", 0o600)
put("agent_only.pub", ed25519Line(3, "private half in an agent")+"\n", 0o644)
put("notes.pub", "not a key\n", 0o644)
put("authorized_keys.revoked-20261001T000000Z", ed25519Line(4, "revoked")+"\n", 0o600)
put("authorized_keys.bak", ed25519Line(5, "copy")+"\n", 0o600)
put("authorized_keys2", ed25519Line(6, "second file")+"\n", 0o600)
put("config", "# BEGIN mesh ssh-client.config\nInclude ~/.ssh/config.d/*\n# END mesh ssh-client.config\n\nHost mine\n User me\nInclude ~/.ssh/config.d/mesh\n", 0o600)
elsewhere := filepath.Join(home, "token")
os.WriteFile(elsewhere, []byte(key), 0o600)
if err := os.Symlink(elsewhere, filepath.Join(dir, "id_token")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(home, "gone"), filepath.Join(dir, "dangling")); err != nil {
t.Fatal(err)
}
return &Client{Home: home}, dir
}
func exists(p string) bool { _, err := os.Lstat(p); return err == nil }
func TestSweepKeepsWhatItKnows(t *testing.T) {
c, dir := sweepHome(t)
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
s, err := c.Sweep(now)
if err != nil {
t.Fatal(err)
}
for _, rel := range []string{"config", "known_hosts", "authorized_keys", "config.d/00-mesh", "config.d/10-forge",
KeyFile, KeyFile + ".pub", "id_ed25519", "id_ed25519.pub", "work_key", "work_key.pub", "work_key-cert.pub", "agent_only.pub", "removed-drone-20261004/id_drone", "authorized_keys2", "id_token"} {
if !exists(filepath.Join(dir, rel)) {
t.Errorf("%s was moved; it is known", rel)
}
}
root := filepath.Join(c.Home, RetiredDir, "20261017T120000Z")
for _, rel := range []string{"config.bak-20260807-015327", "known_hosts.old", "config.d/mesh", "removed-hal-20261002", "notes.pub", "removed-drone-20261004/config",
"authorized_keys.revoked-20261001T000000Z", "authorized_keys.bak", "dangling"} {
if exists(filepath.Join(dir, rel)) {
t.Errorf("%s is still in ~/.ssh", rel)
}
if !exists(filepath.Join(root, rel)) {
t.Errorf("%s is not kept at %s", rel, filepath.Join(root, rel))
}
}
if !exists(filepath.Join(root, "removed-hal-20261002", "config")) {
t.Error("a folder was not moved whole")
}
if len(s.Moved) != 9 {
t.Errorf("moved %d paths, want 9: %+v", len(s.Moved), s.Moved)
}
if raw, _ := os.ReadFile(filepath.Join(dir, "config")); !strings.Contains(string(raw), "Host mine\n User me\nInclude ~/.ssh/config.d/mesh\n") {
t.Errorf("the operator's lines in config changed: %q", raw)
}
if st, _ := os.Stat(filepath.Join(c.Home, RetiredDir)); st == nil || st.Mode().Perm() != 0o700 {
t.Errorf("the retired folder is not 0700: %v", st)
}
again, err := c.Sweep(now.Add(time.Minute))
if err != nil || len(again.Moved) != 0 {
t.Fatalf("a second sweep moved %+v (err %v)", again.Moved, err)
}
if exists(filepath.Join(c.Home, RetiredDir, "20261017T120100Z")) {
t.Error("a sweep that moved nothing made a folder")
}
retired, err := c.Retired()
if err != nil || len(retired) != 1 || retired[0].At != "20261017T120000Z" || len(retired[0].Moved) != 9 {
raw, _ := json.Marshal(retired)
t.Fatalf("Retired = %s (err %v), want one sweep of 9 paths", raw, err)
}
for _, m := range retired[0].Moved {
if !strings.HasPrefix(m.From, dir+"/") || !strings.HasPrefix(m.To, root+"/") || !exists(m.To) {
t.Errorf("listed %+v, not a path moved from ~/.ssh into %s", m, root)
}
}
}
func TestSweepOnceSaysEachMove(t *testing.T) {
c, dir := sweepHome(t)
var said []string
c.SweepOnce(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC), func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
want := "moved " + filepath.Join(dir, "known_hosts.old") + ", which this module does not know, to " +
filepath.Join(c.Home, RetiredDir, "20261017T120000Z", "known_hosts.old")
if !slices.Contains(said, want) || len(said) != 9 {
t.Fatalf("said %q, want 9 lines with %q", said, want)
}
}
func TestSweepLeavesSocketsAndUnreadable(t *testing.T) {
c, dir := sweepHome(t)
sock := filepath.Join(dir, "cm-ace")
if l, err := net.Listen("unix", sock); err == nil {
defer l.Close()
} else {
t.Skipf("no unix socket here: %v", err)
}
locked := filepath.Join(dir, "unreadable")
os.WriteFile(locked, []byte("?"), 0o000)
if f, err := os.Open(locked); err == nil {
f.Close()
t.Skip("running as a user who reads a mode-0 file")
}
s, err := c.Sweep(time.Now())
if err != nil {
t.Fatal(err)
}
if !exists(sock) {
t.Error("a socket was moved")
}
if !exists(locked) || len(s.Left) != 1 || s.Left[0] != locked {
t.Errorf("an unreadable file was not left and named: left %v", s.Left)
}
}
func TestSweepWithoutSSHDir(t *testing.T) {
c := &Client{Home: t.TempDir()}
if s, err := c.Sweep(time.Now()); err != nil || len(s.Moved) != 0 {
t.Fatalf("Sweep = %+v, %v", s, err)
}
}
// Every path the manifest declares under ~/.ssh is one the sweep knows.
func TestDeclaredMatchesManifest(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []struct{ Path string } `json:"resources"`
Facts map[string]struct{ Path string }
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
under := regexp.MustCompile(`^(\$\{machine:account-home\}/)?\.ssh/(.+)$`)
paths := []string{}
for _, r := range m.Resources {
paths = append(paths, r.Path)
}
for _, f := range m.Facts {
paths = append(paths, f.Path)
}
seen := 0
for _, p := range paths {
g := under.FindStringSubmatch(strings.TrimSuffix(p, "/"))
if g == nil {
continue
}
seen++
if !Declared[g[2]] {
t.Errorf("module.json declares ~/.ssh/%s, which the sweep does not know: it would be moved out", g[2])
}
}
if seen < 5 {
t.Errorf("found %d paths under ~/.ssh in module.json; the manifest's shape changed", seen)
}
}
// Review round 1: a folder holding only a socket is gone into, not moved with the socket.
func TestSweepKeepsAFolderHoldingASocket(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh", "cm")
os.MkdirAll(dir, 0o700)
l, err := net.Listen("unix", filepath.Join(dir, "s"))
if err != nil {
t.Skipf("no unix socket here: %v", err)
}
defer l.Close()
c := &Client{Home: home}
if s, err := c.Sweep(time.Now()); err != nil || len(s.Moved) != 0 || !exists(filepath.Join(dir, "s")) {
t.Fatalf("moved %+v (err %v); the socket's folder must stay", s.Moved, err)
}
}
// Review round 1: two sweeps in the same second never share a folder, so neither overwrites the other.
func TestSweepsInOneSecondKeepBoth(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
os.MkdirAll(dir, 0o700)
c := &Client{Home: home}
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
os.WriteFile(filepath.Join(dir, "a"), []byte("1"), 0o600)
first, err := c.Sweep(now)
if err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "a"), []byte("2"), 0o600)
second, err := c.Sweep(now)
if err != nil {
t.Fatal(err)
}
if first.At == second.At {
t.Fatalf("both sweeps used %s", first.At)
}
for at, want := range map[string]string{first.At: "1", second.At: "2"} {
if raw, _ := os.ReadFile(filepath.Join(home, RetiredDir, at, "a")); string(raw) != want {
t.Errorf("%s/a = %q, want %q", at, raw, want)
}
}
if r, _ := c.Retired(); len(r) != 2 {
t.Errorf("Retired lists %d sweeps, want 2", len(r))
}
}
// Review round 1: a link to a pipe below ~/.ssh never blocks the sweep.
func TestSweepDoesNotOpenAPipe(t *testing.T) {
home := t.TempDir()
sub := filepath.Join(home, ".ssh", "sub")
os.MkdirAll(sub, 0o700)
fifo := filepath.Join(home, "fifo")
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
t.Skipf("no fifo here: %v", err)
}
os.Symlink(fifo, filepath.Join(sub, "p"))
done := make(chan error, 1)
go func() { _, err := (&Client{Home: home}).Sweep(time.Now()); done <- err }()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("the sweep blocked on a pipe")
}
}