Keep what ssh reads itself and linked key folders in ~/.ssh, and fail the sweep loudly (issue 1020, ADR 0417)
mesh/merge-gate head 05e6b583e2e3 merged into trunk ce56b491cffd — pass: builds ssh-client → ace, g14, novox, shanks; no bus step; every machine composes w…
mesh/repo-check head 05e6b583e2e3 merged into trunk ce56b491cffd — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered

The sweep from issue 1017 moved known_hosts2, rc and environment, which ssh
and sshd read by default, and moved a link to a folder of keys, breaking every
login through it. It could move a file a tool was still writing. When ~/.local
was on another file system, every sweep failed at its first move and left an
empty folder each round, seen only in the journal. Its list was written after
the moves with errors ignored.

Now those files and linked key folders stay; a link whose target, or the folder
it names, cannot be checked is left and named; a copy of authorized_keys is
moved at any depth; a path changed in the last minute, at any depth, waits for
the next sweep; a retired folder on another file system fails the sweep before
anything is made; the list names each move before it is made; and each sweep's
outcome is kept for ssh_client_check, which shows a failed sweep as a problem.
A key's first line is read only so far.

The catalogue's new checks/sshdir refuses any manifest but ssh-client naming a
path under ~/.ssh (ADR 0415 §6). The README cites §7 for config.d.
This commit is contained in:
2026-10-17 13:36:27 +00:00
parent ce56b491cf
commit 05e6b583e2
9 changed files with 702 additions and 33 deletions
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"reflect"
"slices"
"sort"
"strings"
"sync"
@@ -376,6 +377,37 @@ func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
}
}
// Issue 1020, ADR 0417: the check shows a failed last sweep and a listed move with nothing at its destination
// as problems, and says when no sweep has run.
func TestCheckShowsAFailedSweep(t *testing.T) {
h := home(t, map[string]string{"config": layout["config"], "known_hosts": ""})
f := &fake{answer: func(call) Ran { return Ran{Status: 1} }}
c := client(h, f)
got, _ := c.Check(context.Background(), false)
if !slices.ContainsFunc(got["not_checked"].([]string), func(s string) bool { return strings.Contains(s, "the sweep") }) {
t.Errorf("not_checked = %v, want the sweep named as not run yet", got["not_checked"])
}
if err := c.writeLastSweep(LastSweep{At: "20261017T120000Z", Error: "it broke"}); err != nil {
t.Fatal(err)
}
list := filepath.Join(h, RetiredDir, "20261001T000000Z.json")
os.MkdirAll(filepath.Dir(list), 0o700)
os.WriteFile(list, []byte(`[{"from":"`+filepath.Join(h, ".ssh", "x")+`","to":"`+filepath.Join(h, RetiredDir, "20261001T000000Z", "x")+`"}]`), 0o600)
got, _ = c.Check(context.Background(), false)
var whats []string
for _, x := range got["findings"].([]Finding) {
if x.Severity == "problem" {
whats = append(whats, x.What)
}
}
all := strings.Join(whats, "\n")
for _, want := range []string{"the last sweep (20261017T120000Z) failed: it broke", "nothing is here"} {
if !strings.Contains(all, want) {
t.Errorf("missing problem %q in:\n%s", want, all)
}
}
}
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {