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") } }