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.
511 lines
19 KiB
Go
511 lines
19 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")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|