Merge pull request 'ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415)' (#346) from fix/1017-retire-ssh-leftovers into main

This commit was merged in pull request #346.
This commit is contained in:
2026-10-17 13:07:05 +00:00
6 changed files with 589 additions and 19 deletions
+21 -17
View File
@@ -8,12 +8,15 @@ item 9). `sshd` is the machine's side.
| path | class | how |
|---|---|---|
| `~/.ssh/` | owned | directory, the account's, mode 0700 |
| `~/.ssh/config.d/` | owned | directory, the account's, mode 0700. This is ssh's own drop-in directory: another module that needs a host (a forge, a work bastion) places its own file here |
| `~/.ssh/config.d/` | owned | directory, the account's, mode 0700. It holds only the files this module declares (ADR 0415 §6). As every module's owned path, ssh-client is the only module that writes under `~/.ssh`: another module that needs a host there provides it through a provision, and this module writes it (ADR 0349 §1; the forge's host comes from `forge-account`). A host of the operator's own goes in `~/.ssh/config` below the mesh's region |
| `~/.ssh/config.d/10-forge` | owned | the forge's host, with the machine's own key (ADR 0399) |
| `~/.ssh/mesh_ed25519`, `.pub` | written by the module's own process | the machine's account's key for the forge, made once (keykeeper.go) |
| `~/.ssh/config.d/00-mesh` | owned | a Host block per other machine of the mesh (a roster fact), regenerated whenever a machine joins, leaves or is renamed. It begins with the mesh's header |
| `~/.ssh/config` | written into, **at the start** | the mesh's region, `# BEGIN mesh ssh-client.config` … `# END …`. It holds one `Include ~/.ssh/config.d/*`. Every line below the region is the operator's, kept byte for byte |
| `~/.ssh/authorized_keys` | found | see *The gap* |
| `~/.ssh/known_hosts` | found | see *The gap* |
| private keys | found | never read, never written. `ssh_client_keys` and `ssh_client_check` read a file's first line to recognise a key, and ask `ssh-keygen` about it |
| private keys, public keys, certificates | kept | never read, never written, never moved, at any depth and whatever their name. `ssh_client_keys` and `ssh_client_check` read a file's first line to recognise a key, and ask `ssh-keygen` about it |
| anything else | swept | moved out of `~/.ssh` by the sweep (below) |
### Why an include, not Host blocks in the region
@@ -54,23 +57,24 @@ So both files stay *found*, and this module invents nothing.
Each region is then one more `into: block` resource here. Until then, `ssh_client_check` reads the
files as they are, and `ssh_client_revoke` / `ssh_client_known_host` act on them by hand.
## The one-off clean-up (ADR 0182: the operator removes a predecessor's output, once)
## The sweep: `~/.ssh` holds only what this module knows (ADR 0415, issue 1017)
The mesh removes nothing it did not make. After the first push, a machine that had the predecessor's
layout still holds:
The module owns `~/.ssh`, so it keeps it to what it knows. Its process sweeps when it starts and every
ten minutes. It knows the paths in the table above, every key (a private key by its first line, a
public key or certificate by its line, at any depth and whatever its name) and sockets (ssh's own
runtime). Every other path is moved, whole, into
`~/.local/state/ssh-client/retired/<UTC time>/`, keeping its path relative to `~/.ssh`, and the list
of what moved is written beside it as `<UTC time>.json`. Nothing is deleted. A folder that holds a key
is gone into rather than moved whole, so the key stays where it is. A file or folder that cannot be read to
tell whether it holds a key is left and named in the log.
1. **The predecessor's `~/.ssh/config.d/mesh`.** Its hosts repeat the mesh's, with the same values,
plus one forge host that no module carries yet. Move that host to your own part of
`~/.ssh/config`, or to a work module's drop-in, then delete the file.
2. **The predecessor's header at the top of `~/.ssh/config`**, now just below the mesh's region: the
comment beginning *"Mesh Host blocks are GENERATED"* and its `Include ~/.ssh/config.d/mesh` line.
3. **A predecessor's block of mesh hosts marked *managed by sshd***, on the machines that still have
one.
4. **Backups beside the live files** (`config.bak-*`, `known_hosts.old`, `removed-*`). `ssh_client_check`
lists them as debris.
So the predecessor's `config.d/mesh`, `config.bak-*`, `known_hosts.old`, `removed-*` and
`authorized_keys.revoked-*` leave `~/.ssh` at the first sweep. Each move is logged by the process, and
`ssh_client_check` lists every sweep that moved something, with where each path lies now.
Until you do, `ssh_client_hosts` and `ssh_client_check` list the repeated hosts as duplicates. The
mesh's still win, because they are read first.
The predecessor's lines inside `~/.ssh/config`, below the mesh's region (its header comment and its
`Include ~/.ssh/config.d/mesh`), are the operator's part of a written-into file and stay. ssh skips an
`Include` that matches no file.
## Tools
@@ -80,7 +84,7 @@ They run as the operator account and never escalate. No answer carries a key: fi
|---|---|---|
| `ssh_client_hosts` | r | every Host and Match section in the order ssh reads it, each with file, line and source (`mesh`, `drop-in`, `operator`); duplicates; what is set outside any section |
| `ssh_client_resolve` | r | `ssh -G` for one host: what ssh would use, and which sections matched |
| `ssh_client_check` | r | modes of the directory and every file; keys with no passphrase (`ssh-keygen -y -P ""`, output used only to compare with the `.pub`), weak, old, unused, or whose `.pub` is another key's; one key under several names; the mesh's region first with its include; duplicate hosts; `known_hosts` for the mesh's machines, missing or stale (scanned live, 5 s each in parallel, unless `scan` is false); authorized keys without a comment; debris. It says what it did not check |
| `ssh_client_check` | r | modes of the directory and every file; keys with no passphrase (`ssh-keygen -y -P ""`, output used only to compare with the `.pub`), weak, old, unused, or whose `.pub` is another key's; one key under several names; the mesh's region first with its include; duplicate hosts; `known_hosts` for the mesh's machines, missing or stale (scanned live, 5 s each in parallel, unless `scan` is false); authorized keys without a comment; debris; what the sweep moved out of `~/.ssh` and where each lies now. It says what it did not check |
| `ssh_client_keys` | r | every private key, by its public half: type, size, fingerprint, comment, mode, age, passphrase, whether ssh offers it by itself |
| `ssh_client_authorized` | r | `authorized_keys` by fingerprint, type, size, comment and options |
| `ssh_client_revoke` | a | removes every line with one fingerprint, keeping the file first as `authorized_keys.revoked-<time>`. It refuses the last key (a lockout) and a key in a mesh region (a push would write it back) |
@@ -809,13 +809,17 @@ func (c *Client) Check(ctx context.Context, scan bool) (map[string]any, error) {
if len(debris) > 0 {
add("note", dir, "backups and retired copies lie beside the live files: %s", strings.Join(debris, ", "))
}
retired, err := c.Retired()
if err != nil {
add("problem", filepath.Join(c.Home, RetiredDir), "cannot be read: %v", err)
}
problems := 0
for _, x := range f {
if x.Severity == "problem" {
problems++
}
}
return map[string]any{"ok": problems == 0, "problems": problems, "findings": f, "keys": keyList, "debris": debris, "not_checked": notChecked}, nil
return map[string]any{"ok": problems == 0, "problems": problems, "findings": f, "keys": keyList, "debris": debris, "retired": retired, "not_checked": notChecked}, nil
}
// meshIncludeFirst is whether ~/.ssh/config begins with the mesh's region and it includes config.d.
@@ -52,7 +52,8 @@ func tools(c *Client) []stdio.Tool {
{
Name: "ssh_client_check",
Description: "Everything about ~/.ssh worth a look: modes of the directory and every file, private keys with no passphrase or weak or old, the mesh's region first with its include, " +
"duplicate hosts, known_hosts entries for the mesh's machines that are missing or stale (scanned live unless scan is false), authorized keys without a comment, and debris. Says what it did not check.",
"duplicate hosts, known_hosts entries for the mesh's machines that are missing or stale (scanned live unless scan is false), authorized keys without a comment, " +
"and what the sweep moved out of ~/.ssh because this module does not know it, with where each lies now (ADR 0415). Says what it did not check.",
Input: map[string]any{"scan": map[string]any{"type": "boolean", "description": "ask each of the mesh's machines for its host key now (default true; 5 s each, in parallel)"}},
Run: func(args map[string]any) (any, error) { return c.Check(ctx, flag(args, "scan", true)) },
},
@@ -149,10 +150,20 @@ func startKeeper() {
if node == "" || err != nil || c.Home == "" {
return
}
go keepSwept(c)
k := &KeyKeeper{Home: c.Home, Node: node, Account: u.Username, Run: ExecRunner, State: keysState{}, Now: time.Now}
k.Keep(context.Background(), func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) })
}
// keepSwept sweeps ~/.ssh (sweep.go) now and every KeyRound.
func keepSwept(c *Client) {
say := func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) }
for {
c.SweepOnce(time.Now(), say)
time.Sleep(KeyRound)
}
}
type keysState struct{}
func (keysState) Put(key string, value any) error {
@@ -363,6 +363,17 @@ func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
if got["ok"] != false || len(got["not_checked"].([]string)) == 0 {
t.Errorf("%v", got)
}
// ADR 0415 §4: the check lists what the sweep moved, and where it lies now.
c := client(h, f)
if _, err := c.Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatal(err)
}
got, _ = c.Check(context.Background(), false)
retired := got["retired"].([]Sweep)
if len(retired) != 1 || retired[0].At != "20261017T120000Z" || len(retired[0].Moved) != 2 {
t.Errorf("retired = %+v, want config.d/mesh and config.bak-1 in one sweep", retired)
}
}
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
@@ -0,0 +1,269 @@
package main
// The sweep (novox/hq ADR 0415, issue 1017): ssh-client owns ~/.ssh, so it keeps it to what it knows.
// Every path it does not know is moved, whole, into ~/.local/state/ssh-client/retired/<UTC time>/,
// keeping its path relative to ~/.ssh. Nothing is deleted. A key (private, public or a certificate),
// at any depth and whatever its name, and authorized_keys are never moved (design 29 §3: moving one
// can lock the operator out); a folder holding a key is gone into rather than moved whole. A socket
// is ssh's own runtime and stays. The process sweeps when it starts and every KeyRound (main.go).
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// Declared are the paths under ~/.ssh this module declares (module.json) or its own process makes
// (keykeeper.go). A test checks the manifest against this list.
var Declared = map[string]bool{
"config": true,
"known_hosts": true,
"config.d": true,
"config.d/00-mesh": true,
"config.d/10-forge": true,
KeyFile: true,
KeyFile + ".pub": true,
"authorized_keys": true,
"authorized_keys2": true, // sshd's second AuthorizedKeysFile by default: moving it can lock the operator out
}
// copyOf is a copy of authorized_keys or known_hosts (authorized_keys.revoked-*, known_hosts.old): it
// carries key lines but is no key, and sshd and ssh never read it.
var copyOf = regexp.MustCompile(`^(authorized_keys|known_hosts)[^/]+$`)
// RetiredDir is where the sweep moves what it does not know, under the account's home.
const RetiredDir = ".local/state/ssh-client/retired"
// Moved is one path a sweep moved out of ~/.ssh.
type Moved struct {
From string `json:"from"`
To string `json:"to"`
}
// Sweep is what one sweep did: what it moved, and what it left because it could not tell what it is.
type Sweep struct {
At string `json:"at"`
Moved []Moved `json:"moved"`
Left []string `json:"left,omitempty"`
}
// Sweep moves every path under ~/.ssh this module does not know into a retired folder named by now,
// and writes beside that folder the list of what it moved (<time>.json). It moves nothing when
// everything is known, and makes nothing then.
func (c *Client) Sweep(now time.Time) (Sweep, error) {
dir := c.ssh("")
s := Sweep{At: now.UTC().Format("20060102T150405Z"), Moved: []Moved{}}
if _, err := os.Lstat(dir); err != nil {
if os.IsNotExist(err) {
return s, nil
}
return s, err
}
var unknown []string
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if p == dir {
return err
}
s.Left = append(s.Left, p) // a folder that cannot be read may hold a key
return fs.SkipDir
}
rel, _ := filepath.Rel(dir, p)
switch {
case rel == "." || Declared[rel] || d.Type()&fs.ModeSocket != 0:
return nil
case d.IsDir():
if holdsKey(p) {
return nil // gone into: the key stays where it is, the rest around it goes
}
unknown = append(unknown, rel)
return fs.SkipDir // moved whole
case d.Type()&fs.ModeSymlink != 0:
// A link to a key is a key's name: it stays. Any other link is moved as the link, never
// what it points at.
if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() && keyKind(p) == "key" {
return nil
}
unknown = append(unknown, rel)
return nil
case !d.Type().IsRegular():
unknown = append(unknown, rel)
return nil
case copyOf.MatchString(rel):
unknown = append(unknown, rel)
return nil
}
switch k := keyKind(p); k {
case "key":
return nil
case "unreadable":
s.Left = append(s.Left, p)
return nil
}
unknown = append(unknown, rel)
return nil
})
if err != nil {
return s, err
}
if len(unknown) == 0 {
return s, nil
}
root, err := c.claim(s.At)
if err != nil {
return s, err
}
s.At = filepath.Base(root)
defer func() {
if len(s.Moved) > 0 {
raw, _ := json.MarshalIndent(s.Moved, "", " ")
os.WriteFile(root+".json", raw, 0o600)
}
}()
for _, rel := range unknown {
to := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
return s, err
}
if _, err := os.Lstat(to); err == nil {
return s, fmt.Errorf("could not move %s out of ~/.ssh: %s is already there", rel, to) // never over a kept copy
}
if err := os.Rename(filepath.Join(dir, rel), to); err != nil {
return s, fmt.Errorf("could not move %s out of ~/.ssh: %v", rel, err)
}
s.Moved = append(s.Moved, Moved{From: filepath.Join(dir, rel), To: to})
}
return s, nil
}
// claim makes this sweep's own retired folder, named by its time; a folder of that name already there
// (a sweep in the same second) is never shared: the name takes -2, -3, … instead.
func (c *Client) claim(at string) (string, error) {
base := filepath.Join(c.Home, RetiredDir)
if err := os.MkdirAll(base, 0o700); err != nil {
return "", err
}
for n := 1; n <= 1000; n++ {
root := filepath.Join(base, at)
if n > 1 {
root = fmt.Sprintf("%s-%d", root, n)
}
err := os.Mkdir(root, 0o700)
if err == nil {
return root, nil
}
if !os.IsExist(err) {
return "", err
}
}
return "", fmt.Errorf("no free name for the sweep's folder under %s", base)
}
// keyKind says whether a regular file is a key ("key": a private key, a public key or a certificate),
// cannot be read to tell ("unreadable"), or is neither ("").
func keyKind(path string) string {
if st, err := os.Stat(path); err != nil {
return "unreadable"
} else if !st.Mode().IsRegular() {
return "" // a pipe or a device is no key, and opening one can block
}
f, err := os.Open(path)
if err != nil {
return "unreadable"
}
f.Close()
if header(path) || publicKey(path) {
return "key"
}
return ""
}
// holdsKey is whether a folder holds, at any depth, a key, a socket or a file that cannot be read to
// tell.
func holdsKey(dir string) bool {
found := false
filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
found = true // a folder that cannot be read may hold one
return fs.SkipAll
}
rel, _ := filepath.Rel(dir, p)
if d.Type()&fs.ModeSocket != 0 {
found = true
return fs.SkipAll
}
if (d.Type().IsRegular() || d.Type()&fs.ModeSymlink != 0) && !copyOf.MatchString(d.Name()) && keyKind(p) != "" && rel != "." {
found = true
return fs.SkipAll
}
return nil
})
return found
}
// publicKey reads a small file's first line and says whether it is a public key or a certificate.
func publicKey(path string) bool {
st, err := os.Stat(path)
if err != nil || st.Size() > 16<<10 {
return false
}
raw, err := os.ReadFile(path)
if err != nil {
return false
}
line, _, _ := strings.Cut(string(raw), "\n")
if _, err := ParseKeyLine(line); err == nil {
return true
}
f := strings.Fields(line)
return len(f) > 1 && strings.HasSuffix(f[0], "-cert-v01@openssh.com")
}
// Retired lists the sweeps that moved something, newest first, from the lists each sweep wrote.
func (c *Client) Retired() ([]Sweep, error) {
root := filepath.Join(c.Home, RetiredDir)
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
return []Sweep{}, nil
} else if err != nil {
return nil, err
}
out := []Sweep{}
for _, e := range entries {
at, ok := strings.CutSuffix(e.Name(), ".json")
if !ok || e.IsDir() {
continue
}
raw, err := os.ReadFile(filepath.Join(root, e.Name()))
if err != nil {
return nil, err
}
s := Sweep{At: at}
if err := json.Unmarshal(raw, &s.Moved); err != nil {
return nil, fmt.Errorf("%s: %v", filepath.Join(root, e.Name()), err)
}
out = append(out, s)
}
sort.Slice(out, func(a, b int) bool { return out[a].At > out[b].At })
return out, nil
}
// SweepOnce sweeps and says each move, each path left, and a failure through say.
func (c *Client) SweepOnce(now time.Time, say func(string, ...any)) {
s, err := c.Sweep(now)
for _, m := range s.Moved {
say("moved %s, which this module does not know, to %s", m.From, m.To)
}
for _, l := range s.Left {
say("left %s: it cannot be read to tell whether it is a key", l)
}
if err != nil {
say("the sweep of ~/.ssh: %v", err)
}
}
@@ -0,0 +1,271 @@
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")
}
}