Files
mesh-catalog/modules/ssh-client/cmd/ssh-client-tools/sweep_test.go
T

513 lines
19 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"syscall"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 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",
forgekey.KeyFile, forgekey.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")
}
}
// Issue 1020 (a): the files ssh and sshd read by themselves stay, though this module writes none of them.
func TestSweepKeepsWhatSSHReads(t *testing.T) {
c, dir := sweepHome(t)
for _, rel := range []string{"known_hosts2", "rc", "environment"} {
os.WriteFile(filepath.Join(dir, rel), []byte("kept\n"), 0o600)
}
if _, err := c.Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
}
for rel := range SSHReads {
if !exists(filepath.Join(dir, rel)) {
t.Errorf("%s was moved; ssh or sshd reads it", rel)
}
}
}
// Issue 1020 (b): a link to a folder holding a key is a key's name and stays; a link to a folder without
// one is moved as the link.
func TestSweepKeepsALinkedKeyFolder(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
keys := filepath.Join(home, "keys")
plain := filepath.Join(home, "plain")
os.MkdirAll(dir, 0o700)
os.MkdirAll(keys, 0o700)
os.MkdirAll(plain, 0o700)
os.WriteFile(filepath.Join(keys, "id_work"), []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE\n"), 0o600)
os.WriteFile(filepath.Join(plain, "notes"), []byte("notes\n"), 0o600)
os.Symlink(keys, filepath.Join(dir, "keys"))
os.Symlink(plain, filepath.Join(dir, "plain"))
s, err := (&Client{Home: home}).Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if !exists(filepath.Join(dir, "keys")) {
t.Error("the link to a folder holding a key was moved")
}
if exists(filepath.Join(dir, "plain")) || len(s.Moved) != 1 || !exists(filepath.Join(plain, "notes")) {
t.Errorf("moved %+v; want only the link plain, never what it points at", s.Moved)
}
}
// Issue 1020 (c): a link whose target cannot be checked is left and named, never moved.
func TestSweepLeavesALinkItCannotCheck(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
locked := filepath.Join(home, "locked")
os.MkdirAll(dir, 0o700)
os.MkdirAll(locked, 0o700)
os.WriteFile(filepath.Join(locked, "id_x"), []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n"), 0o600)
os.Chmod(locked, 0o000)
defer os.Chmod(locked, 0o700)
if _, err := os.Stat(filepath.Join(locked, "id_x")); err == nil {
t.Skip("running as a user who reads a mode-0 folder")
}
link := filepath.Join(dir, "id_x")
os.Symlink(filepath.Join(locked, "id_x"), link)
s, err := (&Client{Home: home}).Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if !exists(link) || len(s.Moved) != 0 || !slices.Contains(s.Left, link) {
t.Errorf("moved %+v, left %v; the link must be left and named", s.Moved, s.Left)
}
}
// Issue 1020 (d): a copy of authorized_keys beside a key in a folder is moved, as it is at the top.
func TestSweepMovesACopyBelowTheTop(t *testing.T) {
home := t.TempDir()
sub := filepath.Join(home, ".ssh", "work")
os.MkdirAll(sub, 0o700)
os.WriteFile(filepath.Join(sub, "id_work"), []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n"), 0o600)
os.WriteFile(filepath.Join(sub, "authorized_keys.bak"), []byte(ed25519Line(7, "copy")+"\n"), 0o600)
s, err := (&Client{Home: home}).Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if exists(filepath.Join(sub, "authorized_keys.bak")) || !exists(filepath.Join(sub, "id_work")) || len(s.Moved) != 1 {
t.Errorf("moved %+v; want the copy moved and the key kept", s.Moved)
}
}
// Issue 1020 (e): a retired folder on another file system fails the sweep before it makes anything, and
// the failure is kept for ssh_client_check.
func TestSweepRefusesAnotherFileSystem(t *testing.T) {
home := t.TempDir()
other, err := os.MkdirTemp("/dev/shm", "ssh-client-test-")
if err != nil {
t.Skipf("no /dev/shm here: %v", err)
}
defer os.RemoveAll(other)
a, _ := os.Stat(home)
b, _ := os.Stat(other)
if a.Sys().(*syscall.Stat_t).Dev == b.Sys().(*syscall.Stat_t).Dev {
t.Skip("the temporary folder and /dev/shm are one file system here")
}
if err := os.Symlink(other, filepath.Join(home, ".local")); err != nil {
t.Fatal(err)
}
dir := filepath.Join(home, ".ssh")
os.MkdirAll(dir, 0o700)
os.WriteFile(filepath.Join(dir, "stray"), []byte("?"), 0o600)
c := &Client{Home: home}
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...)) })
if !exists(filepath.Join(dir, "stray")) {
t.Fatal("the stray file is gone")
}
if exists(filepath.Join(other, "state", "ssh-client", "retired")) {
t.Error("the failed sweep made the retired folder")
}
last, err := c.LastSwept()
if err != nil || last == nil || !strings.Contains(last.Error, "another file system") {
t.Fatalf("LastSwept = %+v (err %v), want the failure kept; said %q", last, err, said)
}
}
// Issue 1020 (f): a list that cannot be written stops the sweep before the move it would name; a list
// naming a move not made is marked.
func TestSweepWritesTheListFirst(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
os.MkdirAll(dir, 0o700)
os.WriteFile(filepath.Join(dir, "stray"), []byte("?"), 0o600)
os.MkdirAll(filepath.Join(home, RetiredDir, "20261017T120000Z.json"), 0o700) // the list cannot be written there
c := &Client{Home: home}
s, err := c.Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
if err == nil || len(s.Moved) != 0 || !exists(filepath.Join(dir, "stray")) {
t.Fatalf("Sweep = %+v, %v; want a failure and nothing moved", s, err)
}
if exists(filepath.Join(home, RetiredDir, "20261017T120000Z")) {
t.Error("the failed sweep left its folder")
}
list := filepath.Join(home, RetiredDir, "20261001T000000Z.json")
raw, _ := json.Marshal([]Moved{{From: filepath.Join(dir, "stray"), To: filepath.Join(home, RetiredDir, "20261001T000000Z", "stray")}})
os.WriteFile(list, raw, 0o600)
r, err := c.Retired()
if err != nil || len(r) != 1 || !r[0].Moved[0].Missing {
t.Fatalf("Retired = %+v (err %v), want the move marked missing", r, err)
}
}
// Issue 1020 (g): a path changed in the last Settle is being written and waits for the next sweep.
func TestSweepWaitsForAFileBeingWritten(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
os.MkdirAll(dir, 0o700)
tmp := filepath.Join(dir, ".authorized_keys.123456")
os.WriteFile(tmp, nil, 0o600)
c := &Client{Home: home, Settle: time.Minute}
now := time.Now()
if s, err := c.Sweep(now); err != nil || len(s.Moved) != 0 || !exists(tmp) {
t.Fatalf("Sweep = %+v, %v; a file written just now must stay", s, err)
}
if s, err := c.Sweep(now.Add(2 * time.Minute)); err != nil || len(s.Moved) != 1 {
t.Fatalf("Sweep = %+v, %v; a file settled must be moved", s, err)
}
}
// Issue 1020 (h): a key's first line is recognised, and a large file without a newline is read only so far.
func TestHeaderReadsAShortLine(t *testing.T) {
d := t.TempDir()
key := filepath.Join(d, "k")
big := filepath.Join(d, "big")
os.WriteFile(key, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nX\n"), 0o600)
os.WriteFile(big, []byte(strings.Repeat("a", 1<<20)), 0o600)
if !header(key) || header(big) {
t.Errorf("header(key) = %v, header(big) = %v", header(key), header(big))
}
}
// Issue 1020, ADR 0417: a sweep whose every move failed leaves no folder and no list behind.
func TestSweepThatMovedNothingLeavesNothing(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
os.MkdirAll(dir, 0o700)
os.WriteFile(filepath.Join(dir, "stray"), []byte("?"), 0o600)
os.Chmod(dir, 0o500) // nothing can be moved out of it
defer os.Chmod(dir, 0o700)
if err := os.Rename(filepath.Join(dir, "stray"), filepath.Join(home, "probe")); err == nil {
os.Rename(filepath.Join(home, "probe"), filepath.Join(dir, "stray"))
t.Skip("running as a user who renames out of a mode-0500 folder")
}
s, err := (&Client{Home: home}).Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
if err == nil || len(s.Moved) != 0 {
t.Fatalf("Sweep = %+v, %v; want the move to fail", s, err)
}
if entries, _ := os.ReadDir(filepath.Join(home, RetiredDir)); len(entries) != 0 {
t.Errorf("the sweep that moved nothing left %v", entries)
}
}
// Review round 1 of issue 1020: an old folder holding, deeper down, a file being written is gone into, not
// moved whole with it (ADR 0417 §4).
func TestSweepGoesIntoAFolderWithAFileBeingWritten(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
os.MkdirAll(filepath.Join(dir, "a", "b"), 0o700)
os.WriteFile(filepath.Join(dir, "a", "old"), []byte("old\n"), 0o600)
os.WriteFile(filepath.Join(dir, "a", "b", "id"), nil, 0o600) // ssh-keygen writing a key
hour := time.Now().Add(-time.Hour)
for _, p := range []string{filepath.Join(dir, "a"), filepath.Join(dir, "a", "old")} {
os.Chtimes(p, hour, hour)
}
c := &Client{Home: home, Settle: time.Minute}
s, err := c.Sweep(time.Now())
if err != nil {
t.Fatal(err)
}
if !exists(filepath.Join(dir, "a", "b", "id")) || exists(filepath.Join(dir, "a", "old")) || len(s.Moved) != 1 {
t.Errorf("moved %+v; want only a/old moved and the file being written kept", s.Moved)
}
}
// Review round 1 of issue 1020: a link to a folder holding something that cannot be read is left and named
// (ADR 0417 §3).
func TestSweepNamesALinkedFolderItCannotRead(t *testing.T) {
home := t.TempDir()
dir := filepath.Join(home, ".ssh")
vault := filepath.Join(home, "vault")
os.MkdirAll(dir, 0o700)
os.MkdirAll(filepath.Join(vault, "sub"), 0o700)
os.Chmod(filepath.Join(vault, "sub"), 0o000)
defer os.Chmod(filepath.Join(vault, "sub"), 0o700)
if _, err := os.ReadDir(filepath.Join(vault, "sub")); err == nil {
t.Skip("running as a user who reads a mode-0 folder")
}
link := filepath.Join(dir, "keys")
os.Symlink(vault, link)
s, err := (&Client{Home: home}).Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if !exists(link) || len(s.Moved) != 0 || !slices.Contains(s.Left, link) {
t.Errorf("moved %+v, left %v; the link must be left and named", s.Moved, s.Left)
}
}