mesh/delivery-group group feat/900-node-files checking: composed check out of date: novox/mesh-controller@210f7cf5e64b was judged against trunk 611e2275cf7c, t…
mesh/merge-gate out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
820 lines
29 KiB
Go
820 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// fakeRoot is the sudo -n runner of a test: it records each call, and answers a cat from a table. No test runs
|
|
// sudo (ADR 0391).
|
|
type fakeRoot struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
cat map[string]string
|
|
out map[string]string // the answer to a whole call, "bin arg arg"
|
|
err error
|
|
}
|
|
|
|
func (r *fakeRoot) Run(bin string, args ...string) ([]byte, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls = append(r.calls, bin+" "+strings.Join(args, " "))
|
|
if o, ok := r.out[bin+" "+strings.Join(args, " ")]; ok {
|
|
return []byte(o), nil
|
|
}
|
|
if bin == "/usr/bin/cat" {
|
|
if s, ok := r.cat[args[len(args)-1]]; ok {
|
|
return []byte(s), nil
|
|
}
|
|
}
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
// What root would do in a folder the account may not write, done in the test's tree.
|
|
switch bin {
|
|
case "/usr/bin/cp":
|
|
dest := args[len(args)-1]
|
|
os.Chmod(filepath.Dir(dest), 0o700)
|
|
b, err := os.ReadFile(args[len(args)-2])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, os.WriteFile(dest, b, 0o600)
|
|
case "/usr/bin/mkdir":
|
|
dest := args[len(args)-1]
|
|
for p := filepath.Dir(dest); p != "/"; p = filepath.Dir(p) {
|
|
if _, err := os.Stat(p); err == nil {
|
|
os.Chmod(p, 0o700)
|
|
break
|
|
}
|
|
}
|
|
return nil, os.MkdirAll(dest, 0o755)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// reach makes the fake answer, for a path, what root's readlink -e and stat would: a regular file that leads to itself.
|
|
func (r *fakeRoot) reach(p string) {
|
|
r.out["/usr/bin/readlink -e -- "+p] = p + "\n"
|
|
r.out["/usr/bin/stat -c "+statFormat+" -- "+p] = "regular file|1000|1000|600|-rw-------|1\n"
|
|
}
|
|
|
|
func (r *fakeRoot) called(prefix string) bool {
|
|
for _, c := range r.calls {
|
|
if strings.HasPrefix(c, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type rig struct {
|
|
*Files
|
|
root *fakeRoot
|
|
dir string
|
|
owners map[string]Ident
|
|
told []string
|
|
}
|
|
|
|
// testFiles is a holder over a temporary tree: a fake root runner, a clock the test moves, owners the test
|
|
// names (a test cannot make a file of another account), and a chown that refuses what is not its own.
|
|
func testFiles(t *testing.T) *rig {
|
|
t.Helper()
|
|
r, dir := testReader(t)
|
|
g := &rig{root: &fakeRoot{cat: map[string]string{}, out: map[string]string{}}, dir: dir, owners: map[string]Ident{}}
|
|
r.Root = g.root
|
|
r.Now = func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) }
|
|
seq := 0
|
|
g.Files = &Files{Reader: r, State: filepath.Join(dir, "state"), Owned: map[string]string{}, Node: "shanks",
|
|
OwnerOf: func(p string) (Ident, error) {
|
|
if id, ok := g.owners[p]; ok {
|
|
return id, nil
|
|
}
|
|
return ownerOnDisk(p)
|
|
},
|
|
Lchown: func(p string, uid, gid int) error { return &fs.PathError{Op: "lchown", Path: p, Err: syscall.EPERM} },
|
|
NewID: func() string { seq++; return fmt.Sprintf("t%02d", seq) },
|
|
Tell: func(headline string, lines []string, subject string) error {
|
|
g.told = append(g.told, subject+" | "+headline+" | "+strings.Join(lines, " | "))
|
|
return nil
|
|
}}
|
|
if err := os.MkdirAll(g.State, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
var by = Act{Caller: "person.jochen", Reason: "a test"}
|
|
|
|
func (g *rig) path(name string) string { return filepath.Join(g.dir, name) }
|
|
|
|
func mustRead(t *testing.T, p string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func modeOf(t *testing.T, p string) fs.FileMode {
|
|
t.Helper()
|
|
i, err := os.Lstat(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return i.Mode().Perm()
|
|
}
|
|
|
|
func actLines(t *testing.T, g *rig) []map[string]any {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join(g.State, "acts.log"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []map[string]any
|
|
for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(l), &m); err != nil {
|
|
t.Fatalf("a line of the act log is not JSON: %q", l)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ADR 0394 §1: every write names why.
|
|
func TestAWriteWithoutAReasonIsRefused(t *testing.T) {
|
|
g := testFiles(t)
|
|
if _, err := g.Write(Act{Caller: "x"}, g.path("a"), "x", ""); err == nil || !strings.Contains(err.Error(), "reason") {
|
|
t.Errorf("no reason: %v", err)
|
|
}
|
|
if _, err := os.Stat(g.path("a")); err == nil {
|
|
t.Error("the file was written")
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §3: a new file takes its folder's owner and group, and the mode asked.
|
|
func TestANewFileTakesItsFoldersOwner(t *testing.T) {
|
|
g := testFiles(t)
|
|
g.owners[g.dir] = Ident{4242, 4343}
|
|
if _, err := g.Write(by, g.path("new"), "hello\n", "0600"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mustRead(t, g.path("new")) != "hello\n" || modeOf(t, g.path("new")) != 0o600 {
|
|
t.Errorf("content or mode wrong: %o", modeOf(t, g.path("new")))
|
|
}
|
|
if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("new")) {
|
|
t.Errorf("the owner of the folder was not given: %v", g.root.calls)
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §3, §4: an overwrite keeps the old file's owner and mode, and the old file goes to the trash.
|
|
func TestAnOverwriteKeepsOwnerAndModeAndTrashesTheOld(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("f"), "old\n")
|
|
os.Chmod(g.path("f"), 0o640)
|
|
g.owners[g.path("f")] = Ident{4242, 4343}
|
|
if _, err := g.Write(by, g.path("f"), "new\n", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mustRead(t, g.path("f")) != "new\n" || modeOf(t, g.path("f")) != 0o640 {
|
|
t.Errorf("content %q mode %o", mustRead(t, g.path("f")), modeOf(t, g.path("f")))
|
|
}
|
|
if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("f")) {
|
|
t.Errorf("the old owner was not given back: %v", g.root.calls)
|
|
}
|
|
tr, err := g.Trash()
|
|
if err != nil || len(tr.Items) != 1 || tr.Items[0].Path != g.path("f") {
|
|
t.Fatalf("trash: %+v %v", tr, err)
|
|
}
|
|
if mustRead(t, filepath.Join(g.State, "trash", tr.Items[0].ID, "item")) != "old\n" {
|
|
t.Error("the old content is not in the trash")
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §3: a copy keeps mode and owner, a directory with what it holds.
|
|
func TestACopyKeepsModeAndOwner(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("src/a"), "A\n")
|
|
os.Chmod(g.path("src/a"), 0o750)
|
|
g.owners[g.path("src/a")] = Ident{4242, 4343}
|
|
if _, err := g.Copy(by, g.path("src"), g.path("dst")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mustRead(t, g.path("dst/a")) != "A\n" || modeOf(t, g.path("dst/a")) != 0o750 {
|
|
t.Errorf("copy: %q %o", mustRead(t, g.path("dst/a")), modeOf(t, g.path("dst/a")))
|
|
}
|
|
if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("dst/a")) {
|
|
t.Errorf("owner not kept: %v", g.root.calls)
|
|
}
|
|
if _, err := os.Stat(g.path("src/a")); err != nil {
|
|
t.Error("the source is gone")
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §3: a move keeps mode and owner, and the source is gone.
|
|
func TestAMoveKeepsModeAndOwner(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("a"), "A\n")
|
|
os.Chmod(g.path("a"), 0o700)
|
|
if _, err := g.Move(by, g.path("a"), g.path("b")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mustRead(t, g.path("b")) != "A\n" || modeOf(t, g.path("b")) != 0o700 {
|
|
t.Error("the move lost content or mode")
|
|
}
|
|
if _, err := os.Stat(g.path("a")); err == nil {
|
|
t.Error("the source is still there")
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §4: a delete goes to the trash, restore puts it back, and refuses when the path is taken now.
|
|
func TestADeleteGoesToTheTrashAndRestoreBringsItBack(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("d/f"), "keep\n")
|
|
if _, err := g.Delete(by, g.path("d"), false); err == nil || !strings.Contains(err.Error(), "recursive") {
|
|
t.Errorf("a directory without recursive: %v", err)
|
|
}
|
|
if _, err := g.Delete(by, g.path("d"), true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(g.path("d")); err == nil {
|
|
t.Fatal("not deleted")
|
|
}
|
|
tr, _ := g.Trash()
|
|
if len(tr.Items) != 1 {
|
|
t.Fatalf("trash %+v", tr)
|
|
}
|
|
id := tr.Items[0].ID
|
|
write(t, g.path("d/other"), "x")
|
|
if _, err := g.Restore(by, id); err == nil || !strings.Contains(err.Error(), "there now") {
|
|
t.Errorf("restore over a taken path: %v", err)
|
|
}
|
|
os.RemoveAll(g.path("d"))
|
|
if _, err := g.Restore(by, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mustRead(t, g.path("d/f")) != "keep\n" {
|
|
t.Error("not restored")
|
|
}
|
|
if tr, _ := g.Trash(); len(tr.Items) != 0 {
|
|
t.Errorf("the restored item is still in the trash: %+v", tr)
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §4: an entry older than 7 days is emptied on a trash list and on a write call.
|
|
func TestATrashEntryOlderThanSevenDaysIsEmptied(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("f"), "x")
|
|
if _, err := g.Delete(by, g.path("f"), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := g.Now()
|
|
g.Now = func() time.Time { return now.Add(6*24*time.Hour + 23*time.Hour) }
|
|
if tr, _ := g.Trash(); len(tr.Items) != 1 {
|
|
t.Fatalf("emptied too early: %+v", tr)
|
|
}
|
|
g.Now = func() time.Time { return now.Add(7*24*time.Hour + time.Minute) }
|
|
if tr, _ := g.Trash(); len(tr.Items) != 0 {
|
|
t.Fatalf("not emptied: %+v", tr)
|
|
}
|
|
entries, _ := os.ReadDir(filepath.Join(g.State, "trash"))
|
|
if len(entries) != 0 {
|
|
t.Errorf("the entry is still on disk: %v", entries)
|
|
}
|
|
// and on a write call
|
|
write(t, g.path("g"), "y")
|
|
g.Delete(by, g.path("g"), false)
|
|
g.Now = func() time.Time { return now.Add(30 * 24 * time.Hour) }
|
|
if _, err := g.Mkdir(by, g.path("m"), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, _ = os.ReadDir(filepath.Join(g.State, "trash"))
|
|
if len(entries) != 0 {
|
|
t.Errorf("a write call left an old entry: %v", entries)
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §5: an owned path is refused naming its module and tool; with an override it is changed, logged and
|
|
// told to the operator.
|
|
func TestAnOwnedPathIsRefusedNamingItsOwnerAndPassesWithAnOverride(t *testing.T) {
|
|
g := testFiles(t)
|
|
g.Owned[g.path("etc")] = "mesh-hosts (node-hostname)"
|
|
write(t, g.path("etc/hosts"), "old\n")
|
|
for name, run := range map[string]func() error{
|
|
"write": func() error { _, err := g.Write(by, g.path("etc/hosts"), "x", ""); return err },
|
|
"delete": func() error { _, err := g.Delete(by, g.path("etc/hosts"), false); return err },
|
|
"move": func() error { _, err := g.Move(by, g.path("etc/hosts"), g.path("elsewhere")); return err },
|
|
"into": func() error { _, err := g.Move(by, g.path("elsewhere"), g.path("etc/new")); return err },
|
|
"parent": func() error { _, err := g.Delete(by, g.dir, true); return err },
|
|
} {
|
|
err := run()
|
|
if err == nil || !strings.Contains(err.Error(), "mesh-hosts (node-hostname)") {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
if mustRead(t, g.path("etc/hosts")) != "old\n" {
|
|
t.Fatal("an owned file was changed")
|
|
}
|
|
if len(actLines(t, g)) != 0 || len(g.told) != 0 {
|
|
t.Error("a refused act was logged or told")
|
|
}
|
|
ov := by
|
|
ov.Override = "the module is down and the node cannot resolve"
|
|
if _, err := g.Write(ov, g.path("etc/hosts"), "new\n", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mustRead(t, g.path("etc/hosts")) != "new\n" {
|
|
t.Error("the override did not write")
|
|
}
|
|
lines := actLines(t, g)
|
|
if len(lines) != 1 || lines[0]["override"] != ov.Override {
|
|
t.Errorf("log: %v", lines)
|
|
}
|
|
if len(g.told) != 1 || !strings.HasPrefix(g.told[0], "mesh-files on shanks | ") ||
|
|
!strings.Contains(g.told[0], ov.Override) || !strings.Contains(g.told[0], "mesh-hosts (node-hostname)") ||
|
|
!strings.Contains(g.told[0], "person.jochen") {
|
|
t.Errorf("told: %v", g.told)
|
|
}
|
|
if strings.Contains(g.told[0], "/") && strings.Contains(g.told[0], g.dir) {
|
|
t.Errorf("the notice carries a path, which the operator channel refuses: %v", g.told)
|
|
}
|
|
if l := actLines(t, g); !strings.Contains(fmt.Sprint(l[0]["owned"]), g.path("etc")) {
|
|
t.Errorf("the path is in the act log: %v", l[0])
|
|
}
|
|
// an override on a path nobody owns says nothing
|
|
g.told = nil
|
|
if _, err := g.Write(ov, g.path("free"), "x", ""); err != nil || len(g.told) != 0 {
|
|
t.Errorf("an override that was not needed: %v %v", err, g.told)
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §7: a credential file is refused before it is read, but may be copied and moved.
|
|
func TestACredentialFileIsRefusedToReadButMayBeCopied(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("home/.ssh/id_ed25519"), "key\n")
|
|
if _, err := g.Read(g.path("home/.ssh/id_ed25519"), ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
|
t.Errorf("read: %v", err)
|
|
}
|
|
if g.root.called("/usr/bin/cat") {
|
|
t.Error("it went for the content with root")
|
|
}
|
|
if _, err := g.Copy(by, g.path("home/.ssh/id_ed25519"), g.path("backup_key")); err != nil {
|
|
t.Errorf("copy of a secret place: %v", err)
|
|
}
|
|
if _, err := g.Move(by, g.path("backup_key"), g.path("backup_key2")); err != nil {
|
|
t.Errorf("move of a secret place: %v", err)
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §1: a target on another node is refused until step 2.
|
|
func TestATargetOnAnotherNodeIsRefused(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("a"), "x")
|
|
for _, to := range []string{"ace:/tmp/a", "novox:/etc/hosts"} {
|
|
for _, run := range []func() error{
|
|
func() error { _, err := g.Copy(by, g.path("a"), to); return err },
|
|
func() error { _, err := g.Move(by, g.path("a"), to); return err },
|
|
func() error { _, err := g.Copy(by, to, g.path("b")); return err },
|
|
} {
|
|
if err := run(); err == nil || !strings.Contains(err.Error(), "issue 903") {
|
|
t.Errorf("%s: %v", to, err)
|
|
}
|
|
}
|
|
}
|
|
if _, err := os.Stat(g.path("a")); err != nil {
|
|
t.Error("the source was touched")
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §2: a permission error repeats the act through sudo -n with a fixed binary and argument list.
|
|
func TestAPermissionErrorFallsBackToRoot(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("run as root, nothing is refused")
|
|
}
|
|
g := testFiles(t)
|
|
if err := os.MkdirAll(g.path("locked"), 0o500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Chmod(g.path("locked"), 0o700)
|
|
if _, err := g.Write(by, g.path("locked/f"), "x\n", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !g.root.called("/usr/bin/cp -- ") || !g.root.called("/usr/bin/cp -- "+filepath.Join(g.State, "tmp")) {
|
|
t.Errorf("write did not go through root: %v", g.root.calls)
|
|
}
|
|
g.root.calls = nil
|
|
os.Chmod(g.path("locked"), 0o500)
|
|
if _, err := g.Mkdir(by, g.path("locked/sub"), true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !g.root.called("/usr/bin/mkdir -p -- " + g.path("locked/sub")) {
|
|
t.Errorf("mkdir: %v", g.root.calls)
|
|
}
|
|
// a root that fails is said, not swallowed
|
|
os.Chmod(g.path("locked"), 0o500)
|
|
g.root.err = errors.New("sudo: a password is required")
|
|
if _, err := g.Mkdir(by, g.path("locked/sub2"), false); err == nil || !strings.Contains(err.Error(), "password") {
|
|
t.Errorf("a failing root: %v", err)
|
|
}
|
|
// a read the account may not do is done by cat
|
|
write(t, g.path("secretish"), "line one\nline two\n")
|
|
os.Chmod(g.path("secretish"), 0)
|
|
defer os.Chmod(g.path("secretish"), 0o600)
|
|
g.root.cat[g.path("secretish")] = "line one\nline two\n"
|
|
g.root.reach(g.path("secretish"))
|
|
a, err := g.Read(g.path("secretish"), ReadAsk{})
|
|
if err != nil || a.Text != "line one\nline two\n" {
|
|
t.Errorf("read through root: %+v %v", a, err)
|
|
}
|
|
if !g.root.called("/usr/bin/cat -- " + g.path("secretish")) {
|
|
t.Errorf("calls: %v", g.root.calls)
|
|
}
|
|
}
|
|
|
|
// The root runner is a fixed list of binaries, never a shell.
|
|
func TestTheRootRunnerRunsOnlyAFixedList(t *testing.T) {
|
|
for _, bin := range []string{"/bin/sh", "/usr/bin/bash", "/usr/bin/env", "cat", "/usr/bin/sudo", "/usr/bin/sh", "/usr/bin/grep"} {
|
|
if _, err := (sudoRoot{}).Run(bin, "-c", "id"); err == nil || !strings.Contains(err.Error(), "not one of") {
|
|
t.Errorf("%s: %v", bin, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §6: every write act appends one JSON line, with the caller the runtime proved.
|
|
func TestEveryWriteActAppendsOneLogLine(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("a"), "x")
|
|
acts := []struct {
|
|
verb string
|
|
run func() error
|
|
}{
|
|
{"write", func() error { _, err := g.Write(by, g.path("w"), "x", ""); return err }},
|
|
{"mkdir", func() error { _, err := g.Mkdir(by, g.path("m"), false); return err }},
|
|
{"chmod", func() error { _, err := g.Chmod(by, g.path("a"), "0600", false); return err }},
|
|
{"copy", func() error { _, err := g.Copy(by, g.path("a"), g.path("c")); return err }},
|
|
{"move", func() error { _, err := g.Move(by, g.path("c"), g.path("d")); return err }},
|
|
{"delete", func() error { _, err := g.Delete(by, g.path("d"), false); return err }},
|
|
{"chown", func() error { _, err := g.Chown(by, g.path("a"), "ops", "", false); return err }},
|
|
}
|
|
for i, a := range acts {
|
|
if err := a.run(); err != nil {
|
|
t.Fatalf("%s: %v", a.verb, err)
|
|
}
|
|
lines := actLines(t, g)
|
|
if len(lines) != i+1 {
|
|
t.Fatalf("after %s: %d lines", a.verb, len(lines))
|
|
}
|
|
l := lines[i]
|
|
if l["verb"] != a.verb || l["caller"] != "person.jochen" || l["reason"] != "a test" || l["time"] == nil || l["paths"] == nil {
|
|
t.Errorf("%s: %v", a.verb, l)
|
|
}
|
|
}
|
|
// a refused act adds none
|
|
g.Mkdir(by, g.path("a/b/c"), false)
|
|
if len(actLines(t, g)) != len(acts) {
|
|
t.Error("a failed act was logged")
|
|
}
|
|
}
|
|
|
|
// ADR 0159 / issue 365: the tool takes the caller from the runtime, never from the arguments.
|
|
func TestTheToolRecordsTheCallerTheRuntimeNamed(t *testing.T) {
|
|
g := testFiles(t)
|
|
var tool stdio.Tool
|
|
for _, x := range tools(g.Files) {
|
|
if x.Name == Seat+".mkdir" {
|
|
tool = x
|
|
}
|
|
}
|
|
args := map[string]any{"path": g.path("m"), "reason": "why", "caller": "person.mallory"}
|
|
if _, err := tool.Run(args); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if l := actLines(t, g); len(l) != 1 || l[0]["caller"] != stdio.Unproven {
|
|
t.Errorf("log: %v", l)
|
|
}
|
|
}
|
|
|
|
func TestMkdirChmodAndChown(t *testing.T) {
|
|
g := testFiles(t)
|
|
if _, err := g.Mkdir(by, g.path("a/b"), false); err == nil {
|
|
t.Error("a missing parent was made without parents")
|
|
}
|
|
g.owners[g.dir] = Ident{4242, 4343}
|
|
if _, err := g.Mkdir(by, g.path("a/b"), true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("a")) {
|
|
t.Errorf("a made parent takes the owner of its folder: %v", g.root.calls)
|
|
}
|
|
write(t, g.path("a/b/f"), "x")
|
|
os.Chmod(g.path("a/b/f"), 0o600)
|
|
if _, err := g.Chmod(by, g.path("a"), "u+x,g+r", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if modeOf(t, g.path("a/b/f")) != 0o740 {
|
|
t.Errorf("recursive symbolic chmod: %o", modeOf(t, g.path("a/b/f")))
|
|
}
|
|
if _, err := g.Chmod(by, g.path("a/b/f"), "0644", false); err != nil || modeOf(t, g.path("a/b/f")) != 0o644 {
|
|
t.Errorf("octal: %v %o", err, modeOf(t, g.path("a/b/f")))
|
|
}
|
|
if _, err := g.Chmod(by, g.path("a/b/f"), "banana", false); err == nil {
|
|
t.Error("a bad mode")
|
|
}
|
|
g.root.calls = nil
|
|
if _, err := g.Chown(by, g.path("a/b/f"), "ops", "wheel", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !g.root.called("/usr/bin/chown 1000:998 -- " + g.path("a/b/f")) {
|
|
t.Errorf("chown by name: %v", g.root.calls)
|
|
}
|
|
if _, err := g.Chown(by, g.path("a/b/f"), "nobody-here", "", false); err == nil {
|
|
t.Error("an unknown account")
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §5: without the word the module owns nothing and starts; a bad word is refused.
|
|
func TestWithoutOwnedPathsItOwnsNothing(t *testing.T) {
|
|
if o, err := ownedFrom(""); err != nil || len(o) != 0 {
|
|
t.Errorf("none: %v %v", o, err)
|
|
}
|
|
if o, err := ownedFrom(`{"/etc/hosts":"mesh-hosts (node-hostname)"}`); err != nil || o["/etc/hosts"] == "" {
|
|
t.Errorf("a word: %v %v", o, err)
|
|
}
|
|
if _, err := ownedFrom(`["/etc/hosts"]`); err == nil || !strings.Contains(err.Error(), OwnedWord) {
|
|
t.Errorf("a list: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestItServesTheWriteVerbsToo(t *testing.T) {
|
|
g := testFiles(t)
|
|
got := map[string]bool{}
|
|
for _, tool := range tools(g.Files) {
|
|
got[tool.Name] = true
|
|
}
|
|
for _, v := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
|
|
"delete", "mkdir", "chmod", "chown", "trash", "restore"} {
|
|
if !got[Seat+"."+v] {
|
|
t.Errorf("no %s", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §3, §4: a copy or a move onto an existing target overwrites it: the old one goes to the trash, and
|
|
// the new one keeps the old one's owner and mode.
|
|
func TestACopyOrMoveOntoAnExistingTargetOverwritesIt(t *testing.T) {
|
|
g := testFiles(t)
|
|
write(t, g.path("new"), "new\n")
|
|
write(t, g.path("old"), "old\n")
|
|
os.Chmod(g.path("old"), 0o640)
|
|
g.owners[g.path("old")] = Ident{4242, 4343}
|
|
a, err := g.Copy(by, g.path("new"), g.path("old"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mustRead(t, g.path("old")) != "new\n" || modeOf(t, g.path("old")) != 0o640 {
|
|
t.Errorf("copy: %q %o", mustRead(t, g.path("old")), modeOf(t, g.path("old")))
|
|
}
|
|
if !g.root.called("/usr/bin/chown 4242:4343 -- " + g.path("old")) {
|
|
t.Errorf("the old owner was not kept: %v", g.root.calls)
|
|
}
|
|
if a.Trashed == "" || mustRead(t, filepath.Join(g.State, "trash", a.Trashed, "item")) != "old\n" {
|
|
t.Errorf("the old target is not in the trash: %+v", a)
|
|
}
|
|
write(t, g.path("m"), "moved\n")
|
|
os.Chmod(g.path("old"), 0o600)
|
|
b, err := g.Move(by, g.path("m"), g.path("old"))
|
|
if err != nil || mustRead(t, g.path("old")) != "moved\n" || b.Trashed == "" {
|
|
t.Fatalf("move: %v %+v", err, b)
|
|
}
|
|
if _, err := os.Stat(g.path("m")); err == nil {
|
|
t.Error("the source is still there")
|
|
}
|
|
if modeOf(t, g.path("old")) != 0o600 {
|
|
t.Errorf("the overwritten file's mode is kept: %o", modeOf(t, g.path("old")))
|
|
}
|
|
// a target inside the source, or a source inside the target, is not an overwrite
|
|
write(t, g.path("d/f"), "x")
|
|
if _, err := g.Copy(by, g.path("d"), g.path("d/in")); err == nil {
|
|
t.Error("a copy into itself")
|
|
}
|
|
if _, err := g.Move(by, g.path("d/f"), g.path("d")); err == nil {
|
|
t.Error("a move over its own folder")
|
|
}
|
|
if mustRead(t, g.path("d/f")) != "x" {
|
|
t.Error("the file was lost")
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §7: only read and search refuse a place a secret is kept; the writes do not.
|
|
func TestTheWritesDoNotRefuseASecretPlace(t *testing.T) {
|
|
g := testFiles(t)
|
|
k := g.path("home/.ssh/id_ed25519")
|
|
write(t, k, "key\n")
|
|
for name, run := range map[string]func() error{
|
|
"write": func() error { _, err := g.Write(by, k, "new\n", ""); return err },
|
|
"chmod": func() error { _, err := g.Chmod(by, k, "0600", false); return err },
|
|
"mkdir": func() error { _, err := g.Mkdir(by, g.path("home/.ssh/sub"), false); return err },
|
|
"delete": func() error { _, err := g.Delete(by, k, false); return err },
|
|
} {
|
|
if err := run(); err != nil {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0394 §2: where the account's own rights refuse a read, stat, readlink, list and search repeat it through
|
|
// root with a fixed binary; a secret place is refused by name first, and root is not asked.
|
|
func TestTheReadsFallBackToRoot(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("run as root, nothing is refused")
|
|
}
|
|
g := testFiles(t)
|
|
locked := g.path("locked")
|
|
write(t, locked+"/inner/f", "x\n")
|
|
os.Chmod(locked, 0)
|
|
defer os.Chmod(locked, 0o700)
|
|
in := locked + "/inner"
|
|
g.root.out["/usr/bin/stat -c %F|%u|%g|%a|%A|%s -- "+in] = "directory|1000|1000|700|drwx------|4096\n"
|
|
g.root.out["/usr/bin/stat -c %F|%u|%g|%a|%A|%s -- "+in+"/f"] = "regular file|1000|998|4640|-rwSr-----|2\n"
|
|
st, err := g.Stat([]string{in + "/f"}, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cs := st.Paths[0].Components
|
|
last := cs[len(cs)-1]
|
|
if st.Paths[0].Stopped != "" || last.Type != "file" || last.Mode != "4640" || last.Owner != "ops" ||
|
|
last.Group != "wheel" || last.Size == nil || *last.Size != 2 || cs[len(cs)-2].Type != "directory" {
|
|
t.Errorf("stat through root: %+v", st.Paths[0])
|
|
}
|
|
// readlink
|
|
g.root.out["/usr/bin/readlink -- "+in+"/l"] = "f\n"
|
|
g.root.out["/usr/bin/readlink -e -- "+in+"/l"] = in + "/f\n"
|
|
rl, err := g.Readlink(in + "/l")
|
|
if err != nil || !rl.IsLink || rl.Link != "f" || rl.LeadsTo != in+"/f" || !rl.Exists {
|
|
t.Errorf("readlink through root: %+v %v", rl, err)
|
|
}
|
|
// list
|
|
g.root.out["/usr/bin/find -H "+in+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] =
|
|
"f\t1000\t998\t640\t2\t\tf\x00d\t1000\t1000\t755\t4096\t\tsub dir\x00l\t1000\t1000\t777\t1\tf\tl\x00"
|
|
ls, err := g.List(in)
|
|
if err != nil || ls.Count != 3 {
|
|
t.Fatalf("list through root: %+v %v", ls, err)
|
|
}
|
|
byName := map[string]Entry{}
|
|
for _, e := range ls.Entries {
|
|
byName[e.Name] = e
|
|
}
|
|
if e := byName["f"]; e.Type != "file" || e.Mode != "0640" || e.Group != "wheel" || e.Size == nil || *e.Size != 2 {
|
|
t.Errorf("entry f: %+v", e)
|
|
}
|
|
if byName["sub dir"].Type != "directory" || byName["l"].Type != "link" || byName["l"].Link != "f" {
|
|
t.Errorf("entries: %+v", ls.Entries)
|
|
}
|
|
// search: the files are listed by find, the secret ones skipped by name, the rest read through cat
|
|
g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\x00"] =
|
|
in + "/f\x00" + locked + "/.ssh/id_rsa\x00"
|
|
g.root.cat[in+"/f"] = "one\nneedle here\n"
|
|
g.root.reach(in + "/f")
|
|
sr, err := g.Search(SearchAsk{Text: "needle", Path: locked})
|
|
if err != nil || len(sr.Matches) != 1 || sr.Matches[0].File != in+"/f" || sr.Matches[0].Line != 2 {
|
|
t.Fatalf("search through root: %+v %v", sr, err)
|
|
}
|
|
if len(sr.Skipped) != 1 || !strings.HasSuffix(sr.Skipped[0], "id_rsa") {
|
|
t.Errorf("a credential file was not skipped by name: %+v", sr.Skipped)
|
|
}
|
|
if g.root.called("/usr/bin/cat -- " + locked + "/.ssh/id_rsa") {
|
|
t.Error("root read a credential file")
|
|
}
|
|
// a secret place is refused by name, and root is never asked
|
|
g.root.calls = nil
|
|
if _, err := g.List(g.path("home/.ssh")); err == nil {
|
|
t.Error("a secret place was listed")
|
|
}
|
|
if _, err := g.Read(locked+"/.ssh/id_rsa", ReadAsk{}); err == nil || len(g.root.calls) != 0 {
|
|
t.Errorf("a credential file was read: %v %v", err, g.root.calls)
|
|
}
|
|
}
|
|
|
|
// Review 1, finding 3: before root reads a file, root says where it leads and what it is. A link into a refused
|
|
// place, and a file that is not a regular file, are refused before any cat.
|
|
func TestRootNeverCatsAThroughALinkIntoASecretPlaceOrANonFile(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("run as root, nothing is refused")
|
|
}
|
|
g := testFiles(t)
|
|
locked := g.path("locked")
|
|
write(t, locked+"/x", "x\n")
|
|
os.Chmod(locked, 0)
|
|
defer os.Chmod(locked, 0o700)
|
|
g.root.out["/usr/bin/readlink -e -- "+locked+"/link"] = "/etc/ssl/private/k\n"
|
|
g.root.cat["/etc/ssl/private/k"] = "KEY\n"
|
|
g.root.cat[locked+"/link"] = "KEY\n"
|
|
if _, err := g.Read(locked+"/link", ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
|
t.Errorf("a link into a secret place: %v", err)
|
|
}
|
|
g.root.out["/usr/bin/readlink -e -- "+locked+"/fifo"] = locked + "/fifo\n"
|
|
g.root.out["/usr/bin/stat -c "+statFormat+" -- "+locked+"/fifo"] = "fifo|1000|1000|600|prw-------|0\n"
|
|
g.root.cat[locked+"/fifo"] = "never\n"
|
|
if _, err := g.Read(locked+"/fifo", ReadAsk{}); err == nil || !strings.Contains(err.Error(), "not a file") {
|
|
t.Errorf("a fifo: %v", err)
|
|
}
|
|
// a search meets the same two
|
|
g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\x00"] = locked + "/link\x00" + locked + "/fifo\x00"
|
|
sr, err := g.Search(SearchAsk{Text: "KEY", Path: locked})
|
|
if err != nil || len(sr.Matches) != 0 {
|
|
t.Errorf("search: %+v %v", sr, err)
|
|
}
|
|
for _, c := range g.root.calls {
|
|
if strings.HasPrefix(c, "/usr/bin/cat") {
|
|
t.Errorf("root read: %s", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Review 1, finding 4: a directory target that holds an owned path is replaced only with an override.
|
|
func TestOverwritingAFolderThatHoldsAnOwnedPathIsRefused(t *testing.T) {
|
|
g := testFiles(t)
|
|
g.Owned[g.path("dst/etc")] = "mesh-hosts (node-hostname)"
|
|
write(t, g.path("dst/etc/hosts"), "old\n")
|
|
write(t, g.path("src/a"), "A\n")
|
|
if _, err := g.Copy(by, g.path("src"), g.path("dst")); err == nil || !strings.Contains(err.Error(), "mesh-hosts") {
|
|
t.Errorf("copy: %v", err)
|
|
}
|
|
write(t, g.path("src2/a"), "A\n")
|
|
if _, err := g.Move(by, g.path("src2"), g.path("dst")); err == nil || !strings.Contains(err.Error(), "mesh-hosts") {
|
|
t.Errorf("move: %v", err)
|
|
}
|
|
if mustRead(t, g.path("dst/etc/hosts")) != "old\n" {
|
|
t.Error("the owned file was replaced")
|
|
}
|
|
}
|
|
|
|
// Review 1, finding 5: a copy keeps setuid and setgid: the owner is set before the mode, as a chown clears them.
|
|
func TestACopyKeepsSetuid(t *testing.T) {
|
|
g := testFiles(t)
|
|
g.Lchown = func(p string, uid, gid int) error { return os.Lchown(p, os.Getuid(), os.Getgid()) }
|
|
write(t, g.path("tool"), "x\n")
|
|
if err := os.Chmod(g.path("tool"), 0o755|fs.ModeSetuid|fs.ModeSetgid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g.owners[g.path("tool")] = Ident{4242, 4343}
|
|
if _, err := g.Copy(by, g.path("tool"), g.path("tool2")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, _ := os.Lstat(g.path("tool2"))
|
|
if info.Mode()&fs.ModeSetuid == 0 || info.Mode()&fs.ModeSetgid == 0 {
|
|
t.Errorf("the copy lost setuid or setgid: %v", info.Mode())
|
|
}
|
|
}
|
|
|
|
// Review 1, finding 1: every seat verb the code asks is declared in invokes.
|
|
func TestEverySeatVerbTheCodeAsksIsDeclared(t *testing.T) {
|
|
raw, _ := os.ReadFile("../../module.json")
|
|
var m struct{ Invokes []string }
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
declared := map[string]bool{}
|
|
for _, i := range m.Invokes {
|
|
declared[i] = true
|
|
}
|
|
files, _ := filepath.Glob("*.go")
|
|
asked := map[string]bool{}
|
|
for _, f := range files {
|
|
if strings.HasSuffix(f, "_test.go") {
|
|
continue
|
|
}
|
|
src := mustRead(t, f)
|
|
for _, hit := range regexp.MustCompile(`"(seat:[a-z-]+\.[a-z_-]+)"`).FindAllStringSubmatch(src, -1) {
|
|
asked[hit[1]] = true
|
|
}
|
|
}
|
|
if len(asked) == 0 {
|
|
t.Fatal("found no seat verb asked")
|
|
}
|
|
for a := range asked {
|
|
if !declared[a] {
|
|
t.Errorf("%s is asked but not in invokes %v", a, m.Invokes)
|
|
}
|
|
}
|
|
for d := range declared {
|
|
if !asked[d] {
|
|
t.Errorf("%s is declared but never asked", d)
|
|
}
|
|
}
|
|
}
|