diff --git a/README.md b/README.md index 73aa3f1..3f760a5 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,19 @@ what it did not understand would apply most of a declaration and report success. ]} ``` +**A directory or archive says how it treats files it did not write** (issue 1024, +[`novox/hq` ADR 0422](https://git.novox.be/novox/hq)). `unknown` is `move-out`, `module` or `program`; +absent, nothing about unknown paths is done. `knows` lists paths inside it, relative to its `path`, that +are not declared but belong there; a trailing `/` names a folder and everything in it. `knows` without +`unknown` is refused. For `move-out` the node-engine, after every apply and so on every reconcile, moves +each path inside the directory that nothing declared, no archive unpacked and `knows` does not name, +whole, into `/.local/state/mesh-moved///` beside a `.json` list. The +list names each move before it is made. Nothing is deleted. A path younger than a minute waits for the +next look; a socket, pipe or device is left and said. It is only for a directory under an account's +home on one file system with the kept folder, and otherwise the resource fails and nothing moves. +`module` and `program` are read by the module and the program that owns the path, and the node-engine +does nothing for them. + **It converges rather than executes.** Applying twice changes nothing the second time; applying to a drifted machine returns it. A mode is *maintained*, not merely set — a permission applied at creation is not a permission held. diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index e8b3b3f..06e1a8b 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -2254,6 +2254,12 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto report.Busy = append(report.Busy, link.Busy{ID: b.ID, Container: b.Container, State: b.State, Since: b.Since, Until: b.Until, Said: b.Said}) } + for _, m := range outcome.MovedOut { + report.MovedOut = append(report.MovedOut, link.MovedOut{Resource: m.Resource, From: m.From, To: m.To}) + } + for _, l := range outcome.Left { + report.Left = append(report.Left, link.Left{Resource: l.Resource, Path: l.Path}) + } busyWaiting.Store(outcome.Waiting()) // And what runs here that nobody asked for (novox/hq ADR 0163). if strays, err := apply.Strays(ctx, apply.ExecRunner, updated); err != nil { diff --git a/cmd/mesh-host/move_out_test.go b/cmd/mesh-host/move_out_test.go new file mode 100644 index 0000000..53dc445 --- /dev/null +++ b/cmd/mesh-host/move_out_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + osuser "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/novox/mesh-host/internal/apply" +) + +// TestAMoveOutDirectoryKeepsOnlyWhatItKnows drives the node-engine whole through its command (issue 1024, novox/hq +// ADR 0422): a directory under an account's home that says move-out keeps the file the mesh declared and moves a +// file nobody declared into the kept folder, named in a list, and the run says it moved it. It needs no bus: the +// apply is the one a delivery and the five-minute reconcile both run. +func TestAMoveOutDirectoryKeepsOnlyWhatItKnows(t *testing.T) { + me, err := osuser.Current() + if err != nil { + t.Fatal(err) + } + // The user database lists an account as a person's only at uid 1000 or more; root, as the forge's merge check + // runs, is listed under a number of its own (it can give files to it), by the same name. + listedUID := me.Uid + if uid, _ := strconv.Atoi(me.Uid); uid < 1000 || uid == 65534 { + listedUID = "4242" + } + root := t.TempDir() + home := filepath.Join(root, "home", "agent") + dir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + passwd := filepath.Join(root, "passwd") + line := fmt.Sprintf("%s:x:%s:%s::%s:/bin/sh\n", me.Username, listedUID, me.Gid, home) + if err := os.WriteFile(passwd, []byte(line), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(apply.UseAccountsFileForTest(passwd)) + // Independent of the machine: an arch host with a pacman on PATH that answers and holds nothing, as + // TestReplay233 puts one there (it answers with an error); nothing here declares a package. + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "pacman"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + wasBuilt := builtFor + builtFor = "arch" + t.Cleanup(func() { builtFor = wasBuilt }) + + stray := filepath.Join(dir, "id_stray") + if err := os.WriteFile(stray, []byte("somebody else's key"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(stray, old, old); err != nil { + t.Fatal(err) + } + pipe := filepath.Join(dir, "agent.sock") + if err := syscall.Mkfifo(pipe, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(pipe, old, old); err != nil { + t.Fatal(err) + } + opts := options{state: filepath.Join(root, "state.json")} + raw := []byte(`{"declaration":1,"resources":[` + + `{"id":"ssh.dir","type":"directory","path":"` + dir + `","owner":"` + me.Username + `","unknown":"move-out"},` + + `{"id":"ssh.config","type":"file","path":"` + dir + `/config","content":"Host *\n","owner":"` + me.Username + `"}]}`) + + var said []string + report := applyAndKeep(context.Background(), opts, raw, nil, nil, func(s string) { said = append(said, s) }) + + if report.Refused != "" || len(report.Failed) > 0 { + t.Fatalf("refused or failed: %q %v", report.Refused, report.Failed) + } + if _, err := os.Stat(filepath.Join(dir, "config")); err != nil { + t.Errorf("the declared file is not there: %v", err) + } + if _, err := os.Lstat(stray); err == nil { + t.Error("the file nobody declared stayed in the directory") + } + lists, _ := filepath.Glob(filepath.Join(home, ".local", "state", "mesh-moved", "ssh", "*.json")) + if len(lists) != 1 { + t.Fatalf("one list expected: %v", lists) + } + rawList, err := os.ReadFile(lists[0]) + if err != nil { + t.Fatal(err) + } + var entries []struct{ From, To string } + if err := json.Unmarshal(rawList, &entries); err != nil || len(entries) != 1 { + t.Fatalf("the list: %s %v", rawList, err) + } + if got, err := os.ReadFile(entries[0].To); err != nil || string(got) != "somebody else's key" { + t.Errorf("the moved file is not whole in the kept folder: %q %v", got, err) + } + if len(report.MovedOut) != 1 || report.MovedOut[0].From != stray || report.MovedOut[0].To != entries[0].To { + t.Errorf("the report does not carry the move: %+v", report.MovedOut) + } + if len(report.Left) != 1 || report.Left[0].Path != pipe { + t.Errorf("the report does not list the pipe left in place: %+v", report.Left) + } + if !strings.Contains(strings.Join(said, "\n"), "moved "+stray) { + t.Errorf("the run does not say it moved the file: %v", said) + } +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 1c409a7..bf24228 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -117,6 +117,10 @@ type Report struct { // Busy is every container this apply left running because it said busy, or replaced after it had // waited (novox/hq ADR 0335): a machine whose walk waits for it says why, and how it ended. Busy []BusyWait `json:"busy,omitempty"` + // MovedOut is every path a move-out directory moved into its kept folder (issue 1024, novox/hq ADR 0422). + MovedOut []MovedOut `json:"moved-out,omitempty"` + // Left is every socket, pipe or device a move-out directory holds and did not move (ADR 0422 §4). + Left []LeftInPlace `json:"left,omitempty"` } // Waiting says whether a container of this apply waits to be replaced because it is busy: what makes the @@ -589,6 +593,8 @@ func ApplyMindingWindows( // is a different thing — one is "this machine could not do it", the other is "this was never // a declaration", and they are fixed in different places. var failures []*Error + // Which resources applied without error in this run, for the move-out pass after the loop (issue 1024). + appliedOK := map[string]bool{} // Modules whose own step did not complete. What follows *within such a module* is not attempted; // the rest of the machine is (novox/hq ADR 0136). gated := map[string]bool{} @@ -1011,6 +1017,7 @@ func ApplyMindingWindows( } // Only now. The record follows the fact, never leads it. recordApplied(&known, origin, resource, outcome, kept, keptMode, keptOwner) + appliedOK[resource.Identity()] = true // Its module has been taken, and what was held for it is now the mesh's. A file written // into, or a service whose lifecycle is the machine's, replaced nothing that was found, so // its outcome says what the apply did, not that a cutover happened; a hold from when it was @@ -1082,6 +1089,15 @@ func ApplyMindingWindows( failures = append(failures, identityFailures...) + // A directory or archive that says move-out keeps only what it knows (issue 1024, novox/hq ADR 0422). + movedOut, leftIn, moveFailures := moveOutPass(d, known, appliedOK, log) + report.MovedOut = append(report.MovedOut, movedOut...) + report.Left = append(report.Left, leftIn...) + for _, f := range moveFailures { + f.Done = report + failures = append(failures, f) + } + // A replacement that did not apply — failed, skipped behind its module's step, held — leaves // what it replaces running and recorded, said, for the next apply to hand over. unhanded := make([]string, 0, len(handover)) diff --git a/internal/apply/move_out.go b/internal/apply/move_out.go new file mode 100644 index 0000000..a05d9b8 --- /dev/null +++ b/internal/apply/move_out.go @@ -0,0 +1,371 @@ +package apply + +// A directory or archive that says unknown "move-out" keeps only what it knows (issue 1024, novox/hq ADR 0422). +// +// Everything inside it that nothing declared, no archive unpacked and `knows` does not name is moved, whole, into +// `/.local/state/mesh-moved///`, with a list beside it that names each move before it is +// made (ADR 0417 §7). Nothing is deleted: the owner of the path reacts to a file it did not write by keeping it +// where its writer can find it. The pass runs after the resources of an apply, so a reconcile (every five minutes) +// runs it too. + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// movedOutWait is how old a path is before it is moved: a younger one may be mid-write by whatever made it, and +// waits for the next look (ADR 0417 §4). +const movedOutWait = time.Minute + +// MovedOut is one path a move-out directory moved. +type MovedOut struct { + Resource string `json:"resource"` + From string `json:"from"` + To string `json:"to"` +} + +// LeftInPlace is a socket, pipe or device a move-out directory did not move (ADR 0422 §4). +type LeftInPlace struct { + Resource string `json:"resource"` + Path string `json:"path"` +} + +// renamePath and deviceOf are the machine's calls, variables so a test can fail a move or name another file system. +// renamePath renames from to to without following a link in either parent below home (move_out_linux.go). +var renamePath = renameUnder + +type movedEntry struct { + From string `json:"from"` + To string `json:"to"` +} + +// moveOutOwner is the user and group the kept folders and the list are given: the declared owner, or the home's +// account. +func moveOutOwner(home, owner string) (uid, gid int, err error) { + if owner != "" { + return idsOf(owner) + } + acc, ok := accountsOfHomes()[home] + if !ok { + return 0, 0, fmt.Errorf("%s is not the home of an account", home) + } + if u, g, err := idsOf(acc.Name); err == nil { + return u, g, nil + } + return acc.UID, acc.UID, nil +} + +// knownUnder is what a directory knows of its own contents: the declared targets, the files an archive unpacked, +// `knows` entries, and every folder above one of them. +type knownUnder struct { + exact map[string]bool + prefixes []string + // own are nested directories and archives that say what they do with unknown paths themselves. + own map[string]bool +} + +func (k *knownUnder) add(root, path string) { + path = filepath.Clean(path) + for path != root && strings.HasPrefix(path, root+string(os.PathSeparator)) { + k.exact[path] = true + path = filepath.Dir(path) + } +} + +func (k *knownUnder) knows(path string) bool { + if k.exact[path] { + return true + } + for _, p := range k.prefixes { + if path == p || strings.HasPrefix(path, p+string(os.PathSeparator)) { + return true + } + } + return false +} + +func knownOf(d *declaration.Declaration, known store.State, id, root string, knows []string) *knownUnder { + k := &knownUnder{exact: map[string]bool{}, own: map[string]bool{}} + for _, r := range d.Resources { + target := filepath.Clean(r.Target()) + if target == "" || target == "." { + continue + } + if r.Identity() != id && strings.HasPrefix(target, root+string(os.PathSeparator)) { + k.add(root, target) + switch o := r.(type) { + case *declaration.Directory: + if o.Unknown != "" { + k.own[target] = true + } + case *declaration.Archive: + if o.Unknown != "" { + k.own[target] = true + } + } + } + if a, ok := r.(*declaration.Archive); ok { + if rec, found := known.Find(a.ID); found && rec.Unpacked != nil { + base := filepath.Clean(a.Path) + for _, f := range append(append([]string{}, rec.Unpacked.Files...), rec.Unpacked.Dirs...) { + p := filepath.Join(base, filepath.FromSlash(f)) + if p == root || strings.HasPrefix(p, root+string(os.PathSeparator)) { + k.add(root, p) + } + } + } + } + } + for _, e := range knows { + p := filepath.Join(root, filepath.FromSlash(strings.TrimSuffix(e, "/"))) + if strings.HasSuffix(e, "/") { + k.prefixes = append(k.prefixes, p) + } else { + k.exact[p] = true + } + k.add(root, p) + } + return k +} + +// moveOut moves what the directory at dir does not know, for the resource id. It answers the paths moved. +func moveOut(d *declaration.Declaration, known store.State, id, module, dir, owner string, knows []string, + log func(string)) ([]MovedOut, []LeftInPlace, error) { + dir = filepath.Clean(dir) + home := homeAbove(dir) + if home == "" { + return nil, nil, fmt.Errorf("%s: move-out is only for a directory under an account's home, and %s is under none "+ + "(ADR 0422 §5); nothing was moved", id, dir) + } + k := knownOf(d, known, id, dir, knows) + skip := filepath.Join(keptRoot(home), module) + var candidates []string + var left []string + err := filepath.WalkDir(dir, func(path string, e fs.DirEntry, err error) error { + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if path == dir { + return nil + } + if path == skip || path == keptRoot(home) { + return fs.SkipDir + } + if k.own[path] { + if e.IsDir() { + return fs.SkipDir + } + return nil + } + if k.knows(path) { + if e.IsDir() && !k.exact[path] { + return fs.SkipDir + } + return nil + } + // Unknown. Moved whole, so never descended into. + info, err := e.Info() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + switch { + case info.Mode()&(os.ModeSocket|os.ModeNamedPipe|os.ModeDevice|os.ModeCharDevice) != 0: + left = append(left, path) + case time.Since(info.ModTime()) < movedOutWait: + default: + candidates = append(candidates, path) + } + if e.IsDir() { + return fs.SkipDir + } + return nil + }) + if err != nil { + return nil, nil, fmt.Errorf("%s: cannot look through %s: %w", id, dir, err) + } + for _, p := range left { + log(fmt.Sprintf(" left %s in %s: a socket, pipe or device is not moved (ADR 0422)", p, dir)) + } + var stayed []LeftInPlace + for _, p := range left { + stayed = append(stayed, LeftInPlace{Resource: id, Path: p}) + } + if len(candidates) == 0 { + return nil, stayed, nil + } + sort.Strings(candidates) + + uid, gid, err := moveOutOwner(home, owner) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", id, err) + } + + // The same file system first, so a move is a rename and nothing is copied (ADR 0417 §5). + ancestor := skip + for { + if _, err := os.Lstat(ancestor); err == nil { + break + } + ancestor = filepath.Dir(ancestor) + } + devDir, err1 := deviceOf(dir) + devKept, err2 := deviceOf(ancestor) + if err1 != nil || err2 != nil { + return nil, nil, fmt.Errorf("%s: cannot read the file system of %s or %s: %v %v", id, dir, ancestor, err1, err2) + } + if devDir != devKept { + return nil, nil, fmt.Errorf("%s: %s and %s are on different file systems (devices %d and %d), so a move would "+ + "copy; nothing was moved (ADR 0417 §5)", id, dir, ancestor, devDir, devKept) + } + + // The kept folder and the list, named by the second; a clash in the same second gets -2, -3. + stamp := time.Now().UTC().Format("20060102T150405Z") + kept, listPath := "", "" + for n := 1; ; n++ { + name := stamp + if n > 1 { + name = fmt.Sprintf("%s-%d", stamp, n) + } + kept, listPath = filepath.Join(skip, name), filepath.Join(skip, name+".json") + if !pathExists(kept) && !pathExists(listPath) { + break + } + } + var made []string + cleanup := func() { + _ = os.Remove(listPath) + // In the reverse of the order they were made, so a folder goes after what is in it. + for i := len(made) - 1; i >= 0; i-- { + _ = os.Remove(made[i]) + } + } + chown := func(paths []string) error { + for _, p := range paths { + if err := chownIfNeeded(home, p, uid, gid); err != nil { + return err + } + } + return nil + } + madeNow, err := mkdirAllUnder(home, kept, 0o700) + made = append(made, reversed(madeNow)...) + if err == nil { + err = chown(madeNow) + } + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("%s: cannot make the kept folder %s: %w", id, kept, err) + } + + var list []movedEntry + var moved []MovedOut + var failures []string + for _, from := range candidates { + rel, _ := filepath.Rel(dir, from) + to := filepath.Join(kept, rel) + // Named before it is moved: a move that stops half way leaves its entry (ADR 0417 §7). + list = append(list, movedEntry{From: from, To: to}) + raw, _ := json.MarshalIndent(list, "", " ") + if err := writeUnder(home, listPath, append(raw, '\n'), 0o600); err != nil { + failures = append(failures, fmt.Sprintf("cannot write the list %s: %v", listPath, err)) + break + } + if err := chownIfNeeded(home, listPath, uid, gid); err != nil { + failures = append(failures, err.Error()) + break + } + parents, err := mkdirAllUnder(home, filepath.Dir(to), 0o700) + made = append(made, reversed(parents)...) + if err == nil { + err = chown(parents) + } + if err == nil { + err = renamePath(home, from, to) + } + if err != nil { + failures = append(failures, fmt.Sprintf("cannot move %s to %s: %v", from, to, err)) + continue + } + log(fmt.Sprintf(" moved %s to %s (ADR 0422)", from, to)) + moved = append(moved, MovedOut{Resource: id, From: from, To: to}) + } + if len(moved) == 0 { + cleanup() + } + if len(failures) > 0 { + return moved, stayed, fmt.Errorf("%s: %s", id, strings.Join(failures, "; ")) + } + return moved, stayed, nil +} + +// reversed is paths shallowest first: mkdirAllUnder answers deepest first. +func reversed(in []string) []string { + out := make([]string, len(in)) + for i, p := range in { + out[len(in)-1-i] = p + } + return out +} + +func keptRoot(home string) string { return filepath.Join(home, ".local", "state", "mesh-moved") } + +// chownIfNeeded gives a path to uid and gid through the home-safe call, unless it is theirs already. +func chownIfNeeded(home, path string, uid, gid int) error { + if u, g, ok := ownerIDs(path); ok && u == uid && g == gid { + return nil + } + return chownUnder(home, path, uid, gid) +} + +// moveOutPass runs move-out for every directory and archive of d that says so and applied in this run. +func moveOutPass(d *declaration.Declaration, known store.State, applied map[string]bool, log func(string)) ( + moved []MovedOut, left []LeftInPlace, failures []*Error) { + for _, r := range d.Resources { + var unknown, owner string + var knows []string + switch o := r.(type) { + case *declaration.Directory: + unknown, owner, knows = o.Unknown, o.Owner, o.Knows + case *declaration.Archive: + unknown, owner, knows = o.Unknown, o.Owner, o.Knows + default: + continue + } + if unknown != declaration.UnknownMoveOut || !applied[r.Identity()] { + continue + } + module, _ := moduleOf(r.Identity()) + if module == "" { + module = r.Identity() + } + m, l, err := moveOut(d, known, r.Identity(), module, r.Target(), owner, knows, log) + moved = append(moved, m...) + left = append(left, l...) + if err != nil { + log(fmt.Sprintf(" failed %s (%s): %v", r.Identity(), r.Target(), err)) + failures = append(failures, &Error{Resource: r.Identity(), Err: err}) + } + } + return moved, left, failures +} + +func pathExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} diff --git a/internal/apply/move_out_linux.go b/internal/apply/move_out_linux.go new file mode 100644 index 0000000..5f4d5cc --- /dev/null +++ b/internal/apply/move_out_linux.go @@ -0,0 +1,61 @@ +//go:build linux + +package apply + +import ( + "fmt" + "path/filepath" + "syscall" + + "golang.org/x/sys/unix" +) + +// deviceOf is the device a path lives on; a variable so a test can name another file system. Below a home it is +// read through a descriptor opened without following a link (issue 1024). +var deviceOf = func(path string) (uint64, error) { + var st unix.Stat_t + if home := homeAbove(path); home != "" { + fd, err := openDirUnder(home, path) + if err != nil { + return 0, err + } + defer unix.Close(fd) + if err := unix.Fstat(fd, &st); err != nil { + return 0, err + } + return uint64(st.Dev), nil + } + if err := unix.Stat(path, &st); err != nil { + return 0, err + } + return uint64(st.Dev), nil +} + +// renameUnder moves from to to, both below home, through descriptors of their parents opened without following a +// link, and never over something that is there: an account that owns the folders cannot point either at another +// place between the look and the move (issue 1024, novox/hq ADR 0266). +func renameUnder(home, from, to string) error { + fromDir, err := openDirUnder(home, filepath.Dir(from)) + if err != nil { + return err + } + defer unix.Close(fromDir) + toDir, err := openDirUnder(home, filepath.Dir(to)) + if err != nil { + return err + } + defer unix.Close(toDir) + if err := unix.Renameat2(fromDir, filepath.Base(from), toDir, filepath.Base(to), unix.RENAME_NOREPLACE); err != nil { + return fmt.Errorf("rename %s to %s: %w", from, to, err) + } + return nil +} + +// ownerIDs is the user and group of a path, without following a link. +func ownerIDs(path string) (uid, gid int, ok bool) { + var st syscall.Stat_t + if err := syscall.Lstat(path, &st); err != nil { + return 0, 0, false + } + return int(st.Uid), int(st.Gid), true +} diff --git a/internal/apply/move_out_other.go b/internal/apply/move_out_other.go new file mode 100644 index 0000000..5363ad4 --- /dev/null +++ b/internal/apply/move_out_other.go @@ -0,0 +1,19 @@ +//go:build !linux + +package apply + +import "os" + +var deviceOf = func(string) (uint64, error) { return 0, nil } + +func ownerIDs(string) (int, int, bool) { return 0, 0, false } + +func renameUnder(home, from, to string) error { + if err := refuseLinksUnderHome(from); err != nil { + return err + } + if err := refuseLinksUnderHome(to); err != nil { + return err + } + return os.Rename(from, to) +} diff --git a/internal/apply/move_out_test.go b/internal/apply/move_out_test.go new file mode 100644 index 0000000..21567ad --- /dev/null +++ b/internal/apply/move_out_test.go @@ -0,0 +1,355 @@ +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") + } +} diff --git a/internal/apply/test_hooks.go b/internal/apply/test_hooks.go new file mode 100644 index 0000000..fb8dc96 --- /dev/null +++ b/internal/apply/test_hooks.go @@ -0,0 +1,10 @@ +package apply + +// UseAccountsFileForTest makes the accounts and homes this package knows come from the user database at path, until +// the returned function is called. For the node-engine's own integration tests, which cannot name a home any other +// way; nothing in the node-engine calls it (issue 1024). +func UseAccountsFileForTest(path string) (restore func()) { + was := passwdFile + passwdFile = path + return func() { passwdFile = was } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index c66e9e7..0475d6a 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -124,6 +124,60 @@ type Directory struct { // Owner is the user this belongs to, by name. Absent means root, which is what everything // managed was until users existed. Owner string `json:"owner,omitempty"` + // Unknown says how the directory treats a path inside it that nothing declared (issue 1024, novox/hq ADR 0422): + // move-out, module or program. Empty means not said, and nothing moves. + Unknown string `json:"unknown,omitempty"` + // Knows names paths relative to Path that are not declared but belong here; a trailing "/" names a folder and + // everything in it. + Knows []string `json:"knows,omitempty"` +} + +// How a directory or archive treats what it did not write (issue 1024, ADR 0422). +const ( + UnknownMoveOut = "move-out" + UnknownModule = "module" + UnknownProgram = "program" +) + +// checkUnknown judges the unknown and knows fields of a directory or an archive. +func checkUnknown(where, kind, unknown string, knows []string) []string { + var problems []string + switch unknown { + case "", UnknownMoveOut, UnknownModule, UnknownProgram: + default: + problems = append(problems, fmt.Sprintf( + "%s: a %s's unknown is %q; it is %q, %q or %q (issue 1024)", where, kind, unknown, + UnknownMoveOut, UnknownModule, UnknownProgram)) + } + if len(knows) > 0 && unknown == "" { + problems = append(problems, where+": knows is given without unknown, and says nothing without it") + } + seen := map[string]bool{} + for _, k := range knows { + switch { + case k == "": + problems = append(problems, where+": knows holds an empty entry") + continue + case strings.HasPrefix(k, "/"): + problems = append(problems, fmt.Sprintf("%s: knows entry %q is absolute; it is relative to the path", where, k)) + continue + } + bad := false + for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") { + if c == "" || c == "." || c == ".." { + bad = true + } + } + if bad { + problems = append(problems, fmt.Sprintf("%s: knows entry %q holds an empty, . or .. component", where, k)) + continue + } + if seen[k] { + problems = append(problems, fmt.Sprintf("%s: knows entry %q is repeated", where, k)) + } + seen[k] = true + } + return problems } func (d *Directory) Identity() string { return d.ID } @@ -141,6 +195,7 @@ func (d *Directory) validate(where string, _ bool) []string { if len(mode) == 4 && mode[0] == '2' { mode = "0" + mode[1:] } + problems = append(problems, checkUnknown(where, "directory", d.Unknown, d.Knows)...) return append(problems, checkMode(where, mode)...) } @@ -696,6 +751,9 @@ type Archive struct { Path string `json:"path"` // Owner is the user the unpacked files belong to. Absent means root. Owner string `json:"owner,omitempty"` + // Unknown and Knows are as a directory's (issue 1024, ADR 0422). + Unknown string `json:"unknown,omitempty"` + Knows []string `json:"knows,omitempty"` } func (a *Archive) Identity() string { return a.ID } @@ -716,6 +774,7 @@ func (a *Archive) validate(where string, _ bool) []string { problems = append(problems, where+ ": an archive is pinned by digest, as sha256:<64 hex characters>") } + problems = append(problems, checkUnknown(where, "archive", a.Unknown, a.Knows)...) return problems } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index aebdac7..36063d4 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -545,3 +545,51 @@ func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) { } } } + +// Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write. + +func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) { + d, err := Parse([]byte(`{"declaration":1,"resources":[ + {"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]}, + {"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"} + ]}`)) + if err != nil { + t.Fatalf("refused: %v", err) + } + dir := d.Resources[0].(*Directory) + if dir.Unknown != UnknownMoveOut || len(dir.Knows) != 2 { + t.Errorf("directory: %+v", dir) + } + if ar := d.Resources[1].(*Archive); ar.Unknown != UnknownModule { + t.Errorf("archive: %+v", ar) + } + for _, v := range []string{UnknownMoveOut, UnknownModule, UnknownProgram} { + if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/x","unknown":"` + v + `"}]}`)); err != nil { + t.Errorf("%s refused: %v", v, err) + } + } +} + +func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) { + digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"` + cases := map[string]string{ + "bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`, + "empty entry": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":[""]}`, + "absolute": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["/etc"]}`, + "empty comp": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a//b"]}`, + "dot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["./a"]}`, + "dotdot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a/../b"]}`, + "repeat": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a","a"]}`, + "no unknown": `{"id":"d","type":"directory","path":"/x","knows":["a"]}`, + "archive bad": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","unknown":"nope"}`, + "archive knws": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","knows":["a"]}`, + } + for name, res := range cases { + t.Run(name, func(t *testing.T) { + refusal := refusalFor(t, `{"declaration":1,"resources":[`+res+`]}`) + if !strings.Contains(strings.Join(refusal.Problems, "\n"), `resource "d"`) { + t.Errorf("resource not named: %v", refusal.Problems) + } + }) + } +} diff --git a/internal/link/messages.go b/internal/link/messages.go index 319fbd7..f29659a 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -182,6 +182,11 @@ type Report struct { // older than BusyContract, which never waits. Busy []Busy `json:"busy,omitempty"` + // MovedOut is every path a move-out directory moved into its kept folder, and Left every socket, pipe or + // device it held and did not move (issue 1024, novox/hq ADR 0422). + MovedOut []MovedOut `json:"moved-out,omitempty"` + Left []Left `json:"left,omitempty"` + // Strays is what runs on the machine that the mesh neither wrote nor holds (novox/hq ADR // 0163): containers nobody declared and nobody holds, the ones a cutover leaves behind. Strays []Stray `json:"strays,omitempty"` @@ -792,6 +797,19 @@ type Busy struct { Said string `json:"said,omitempty"` } +// MovedOut is one path a move-out directory moved (issue 1024). +type MovedOut struct { + Resource string `json:"resource"` + From string `json:"from"` + To string `json:"to"` +} + +// Left is one socket, pipe or device a move-out directory did not move (issue 1024). +type Left struct { + Resource string `json:"resource"` + Path string `json:"path"` +} + // A Stray is a container the mesh neither wrote nor holds (ADR 0163). type Stray struct { Kind string `json:"kind"`