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
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.
356 lines
13 KiB
Go
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")
|
|
}
|
|
}
|