Files
mesh-host/internal/apply/move_out_test.go
T
mesh-tools d237dee4c9
mesh/merge-gate head d237dee4c9e3 merged into trunk cd13f7726ef0 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head d237dee4c9e3 merged into trunk cd13f7726ef0 — fail: its merge-check.sh failed: --- FAIL: TestAMoveOutDirectoryKeepsOnlyWhatItKnows (0.…
mesh/delivery superseded: a newer head of the same pull request
Let a directory say how it reacts to files it does not know, and move out what a move-out directory does not know (issue 1024, ADR 0422)
A directory or archive resource carries `unknown` (move-out, module, program) and `knows`. For move-out under a home, every look moves each path the declaration does not know into ~/.local/state/mesh-moved/<module>/<time>/, the list written before each move, without following a link, a young path waiting, a socket or pipe left and reported, nothing made when nothing moved, and another file system refused. Reviewed in two rounds.
2026-10-17 16:09:33 +00:00

356 lines
13 KiB
Go

package apply
// Issue 1024, novox/hq ADR 0422: a directory that says unknown "move-out" keeps only what it knows, and moves every
// other path into a kept folder under the account's home, listed before it is moved.
import (
"context"
"encoding/json"
"errors"
"os"
osuser "os/user"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/novox/mesh-host/internal/store"
"golang.org/x/sys/unix"
)
// me is the account running the test, which owns the home the test names.
func me() string {
u, err := osuser.Current()
if err != nil {
panic(err)
}
return u.Username
}
// aMoveOutHome is a home the test names, of the account running the test, so no chown needs privilege.
func aMoveOutHome(t *testing.T) string {
t.Helper()
home := filepath.Join(t.TempDir(), "home", "agent")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
wasHomes, wasAccounts := homes, accountsOfHomes
homes = func() []string { return []string{home} }
accountsOfHomes = func() map[string]homeAccount {
return map[string]homeAccount{home: {Name: me(), UID: os.Getuid()}}
}
t.Cleanup(func() { homes, accountsOfHomes = wasHomes, wasAccounts })
return home
}
func put(t *testing.T, path, content string, age time.Duration) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-age)
if err := os.Chtimes(path, old, old); err != nil {
t.Fatal(err)
}
}
func keptLists(t *testing.T, home string) []string {
t.Helper()
out, _ := filepath.Glob(filepath.Join(keptRoot(home), "*", "*.json"))
return out
}
func readList(t *testing.T, path string) []movedEntry {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var list []movedEntry
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatal(err)
}
return list
}
func TestAMoveOutDirectoryMovesOnlyWhatIsNotKnown(t *testing.T) {
home := aMoveOutHome(t)
dir := filepath.Join(home, ".ssh")
outside := filepath.Join(t.TempDir(), "target")
put(t, outside, "elsewhere", time.Hour)
put(t, filepath.Join(dir, "config"), "declared", time.Hour)
put(t, filepath.Join(dir, "known_hosts"), "knows", time.Hour)
put(t, filepath.Join(dir, "x", "inside"), "in x", time.Hour)
put(t, filepath.Join(dir, "stray"), "stray content", time.Hour)
put(t, filepath.Join(dir, "oldfolder", "deep", "f"), "deep", time.Hour)
put(t, filepath.Join(dir, "young"), "just made", 5*time.Second)
if err := os.Symlink(outside, filepath.Join(dir, "alink")); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-time.Hour)
_ = os.Chtimes(filepath.Join(dir, "oldfolder"), old, old)
_ = os.Chtimes(filepath.Join(dir, "x"), old, old)
// A named pipe stands for the socket: a socket's path is too long under a test directory to bind.
if err := syscall.Mkfifo(filepath.Join(dir, "agent.sock"), 0o644); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(filepath.Join(dir, "agent.sock"), old, old)
// The directory itself and its declared file are old enough too.
// The symbolic link is older than a minute as well.
ts := []unix.Timespec{unix.NsecToTimespec(old.UnixNano()), unix.NsecToTimespec(old.UnixNano())}
if err := unix.UtimesNanoAt(unix.AT_FDCWD, filepath.Join(dir, "alink"), ts, unix.AT_SYMLINK_NOFOLLOW); err != nil {
t.Fatal(err)
}
var logged []string
report, _, err := Apply(context.Background(), archHost(t), declare(t,
`{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out","knows":["known_hosts","x/"]},`+
`{"id":"mod.config","type":"file","path":"`+dir+`/config","content":"declared"}`),
store.State{}, store.OriginDeclared, noServices, func(s string) { logged = append(logged, s) }, nil)
if err != nil {
t.Fatal(err)
}
_ = report
for _, kept := range []string{"config", "known_hosts", "x/inside", "young", "agent.sock"} {
if !exists(filepath.Join(dir, kept)) {
t.Errorf("%s should stay", kept)
}
}
for _, gone := range []string{"stray", "oldfolder", "alink"} {
if exists(filepath.Join(dir, gone)) {
t.Errorf("%s should have moved", gone)
}
}
lists := keptLists(t, home)
if len(lists) != 1 {
t.Fatalf("one list expected, got %v", lists)
}
if !strings.Contains(lists[0], filepath.Join("mesh-moved", "mod")) {
t.Errorf("the module's name is not in the kept path: %s", lists[0])
}
list := readList(t, lists[0])
if len(list) != 3 {
t.Fatalf("the list names three paths: %+v", list)
}
kept := strings.TrimSuffix(lists[0], ".json")
for _, e := range list {
if !strings.HasPrefix(e.To, kept+string(os.PathSeparator)) || !exists(e.To) {
t.Errorf("not at %s: %+v", kept, e)
}
}
if b, _ := os.ReadFile(filepath.Join(kept, "stray")); string(b) != "stray content" {
t.Errorf("content changed: %q", b)
}
if b, _ := os.ReadFile(filepath.Join(kept, "oldfolder", "deep", "f")); string(b) != "deep" {
t.Errorf("the folder was not moved whole: %q", b)
}
if info, err := os.Lstat(filepath.Join(kept, "alink")); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Errorf("the link is not still a link: %v", err)
}
if !strings.Contains(strings.Join(logged, "\n"), "(ADR 0422)") {
t.Errorf("the moves were not logged: %v", logged)
}
if len(report.Left) != 1 || filepath.Base(report.Left[0].Path) != "agent.sock" {
t.Errorf("the report does not list the pipe it left: %+v", report.Left)
}
if len(report.MovedOut) != 3 {
t.Errorf("the report does not count the moves: %+v", report.MovedOut)
}
}
func TestAMoveOutListIsWrittenBeforeTheMove(t *testing.T) {
home := aMoveOutHome(t)
dir := filepath.Join(home, "d")
put(t, filepath.Join(dir, "a"), "a", time.Hour)
put(t, filepath.Join(dir, "b"), "b", time.Hour)
was := renamePath
renamePath = func(h, from, to string) error {
if filepath.Base(from) == "b" {
return errors.New("injected")
}
return was(h, from, to)
}
t.Cleanup(func() { renamePath = was })
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"}`, nil, store.State{})
if err == nil {
t.Fatal("a failed move must fail the resource")
}
if !exists(filepath.Join(dir, "b")) || exists(filepath.Join(dir, "a")) {
t.Error("b stays, a moved")
}
lists := keptLists(t, home)
if len(lists) != 1 {
t.Fatalf("lists: %v", lists)
}
names := map[string]bool{}
for _, e := range readList(t, lists[0]) {
names[filepath.Base(e.From)] = true
}
if !names["a"] || !names["b"] {
t.Errorf("the entry for the failed move was not listed first: %v", names)
}
}
func TestAMoveOutThatMovedNothingMakesNothing(t *testing.T) {
home := aMoveOutHome(t)
dir := filepath.Join(home, "d")
put(t, filepath.Join(dir, "known"), "k", time.Hour)
put(t, filepath.Join(dir, "young"), "y", time.Second)
if _, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out","knows":["known"]}`, nil, store.State{}); err != nil {
t.Fatal(err)
}
if exists(keptRoot(home)) {
t.Error("a kept folder was made with nothing to move")
}
// And when every move fails, what was made goes.
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
was := renamePath
renamePath = func(string, string, string) error { return errors.New("injected") }
t.Cleanup(func() { renamePath = was })
if _, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out","knows":["known"]}`, nil, store.State{}); err == nil {
t.Fatal("expected a failure")
}
if len(keptLists(t, home)) != 0 {
t.Error("a list stayed though nothing moved")
}
if matches, _ := filepath.Glob(filepath.Join(keptRoot(home), "*", "*")); len(matches) != 0 {
t.Errorf("a kept folder stayed though nothing moved: %v", matches)
}
}
func TestAMoveOutOfANestedStrayThatFailsLeavesNoKeptFolder(t *testing.T) {
home := aMoveOutHome(t)
dir := filepath.Join(home, "d")
put(t, filepath.Join(dir, "x", "f"), "declared", time.Hour)
put(t, filepath.Join(dir, "x", "stray"), "s", time.Hour)
was := renamePath
renamePath = func(string, string, string) error { return errors.New("injected") }
t.Cleanup(func() { renamePath = was })
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"},`+
`{"id":"mod.f","type":"file","path":"`+dir+`/x/f","content":"declared","owner":"`+me()+`"}`, nil, store.State{})
if err == nil {
t.Fatal("expected a failure")
}
if exists(keptRoot(home)) {
matches, _ := filepath.Glob(filepath.Join(keptRoot(home), "*", "*"))
t.Errorf("something was left behind though nothing moved: %v", matches)
}
}
// A link swapped into a parent folder, on either side, between the look and the move, is not followed.
func TestAMoveOutDoesNotFollowALinkSwappedIntoAParent(t *testing.T) {
for _, side := range []string{"from", "to"} {
t.Run(side, func(t *testing.T) {
home := aMoveOutHome(t)
dir := filepath.Join(home, "d")
outside := filepath.Join(t.TempDir(), "etc")
put(t, filepath.Join(outside, "stray"), "root's", time.Hour)
put(t, filepath.Join(dir, "x", "f"), "declared", time.Hour)
put(t, filepath.Join(dir, "x", "stray"), "s", time.Hour)
was := renamePath
renamePath = func(h, from, to string) error {
victim := filepath.Dir(from)
if side == "to" {
victim = filepath.Dir(to)
_ = os.Remove(filepath.Join(victim, "nothing"))
}
if err := os.RemoveAll(victim); err != nil {
t.Fatal(err)
}
if side == "from" {
put(t, filepath.Join(outside, "other"), "o", time.Hour)
}
if err := os.Symlink(outside, victim); err != nil {
t.Fatal(err)
}
return was(h, from, to)
}
t.Cleanup(func() { renamePath = was })
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"},`+
`{"id":"mod.f","type":"file","path":"`+dir+`/x/f","content":"declared","owner":"`+me()+`"}`, nil, store.State{})
if err == nil {
t.Fatal("a move through a swapped link must fail")
}
if b, e := os.ReadFile(filepath.Join(outside, "stray")); e != nil || string(b) != "root's" {
t.Errorf("the file outside the home was touched: %q %v", b, e)
}
entries, _ := os.ReadDir(outside)
if side == "to" && len(entries) != 1 || side == "from" && len(entries) != 2 {
t.Errorf("something was moved into or out of the folder outside the home: %v", entries)
}
})
}
}
func TestOtherUnknownValuesAndNoValueMoveNothing(t *testing.T) {
home := aMoveOutHome(t)
for _, v := range []string{`,"unknown":"program"`, `,"unknown":"module"`, ``} {
dir := filepath.Join(home, "d")
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
if _, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`"`+v+`}`, nil, store.State{}); err != nil {
t.Fatal(err)
}
if !exists(filepath.Join(dir, "stray")) || exists(keptRoot(home)) {
t.Errorf("%q moved something", v)
}
}
}
func TestAMoveOutArchiveKeepsWhatItUnpackedAndMovesAStray(t *testing.T) {
home := aMoveOutHome(t)
dir := filepath.Join(home, "theme")
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
res, _ := archiveDecl(t, "mod.theme", dir, map[string]string{"one.txt": "1", "sub/two.txt": "2"}, "")
res = strings.Replace(res, `"path":`, `"owner":"`+me()+`","unknown":"move-out","path":`, 1)
if _, _, err := Apply(context.Background(), archHost(t), declare(t, res), store.State{},
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
for _, f := range []string{"one.txt", "sub/two.txt"} {
if !exists(filepath.Join(dir, f)) {
t.Errorf("%s was moved", f)
}
}
if exists(filepath.Join(dir, "stray")) || len(keptLists(t, home)) != 1 {
t.Error("the stray file did not move")
}
}
func TestMoveOutNeedsAHome(t *testing.T) {
aMoveOutHome(t)
dir := filepath.Join(t.TempDir(), "elsewhere")
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"}`, nil, store.State{})
if err == nil || !strings.Contains(err.Error(), "home") {
t.Fatalf("expected a refusal naming the home: %v", err)
}
if !exists(filepath.Join(dir, "stray")) {
t.Error("something moved")
}
}
func TestMoveOutRefusesAnotherFileSystem(t *testing.T) {
home := aMoveOutHome(t)
dir := filepath.Join(home, "d")
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
was := deviceOf
deviceOf = func(p string) (uint64, error) {
if p == dir {
return 1, nil
}
return 2, nil
}
t.Cleanup(func() { deviceOf = was })
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"}`, nil, store.State{})
if err == nil || !strings.Contains(err.Error(), "file system") {
t.Fatalf("expected a refusal naming the file systems: %v", err)
}
if !exists(filepath.Join(dir, "stray")) || exists(keptRoot(home)) {
t.Error("something moved or was made")
}
}