//go:build unix package hostguard import ( "bufio" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "golang.org/x/sys/unix" ) func TestGuardProcess(t *testing.T) { if os.Getenv("CARLOS_HOSTGUARD_TEST_CHILD") == "1" { unlock, err := acquire(t.Context(), os.Getenv("CARLOS_HOSTGUARD_TEST_LOCKDIR"), "shop.example") if err != nil { t.Fatal(err) } defer unlock() fmt.Fprintln(os.Stdout, "locked") if _, err := io.Copy(io.Discard, os.Stdin); err != nil { t.Fatal(err) } return } f := newGuardFixture(t) host := f.obs.Host if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } captured, err := Capture(f.g.DataDir, host) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGuardProcess$", "-test.count=1") cmd.Dir = t.TempDir() cmd.Env = append(os.Environ(), "CARLOS_HOSTGUARD_TEST_CHILD=1", "CARLOS_HOSTGUARD_TEST_LOCKDIR="+f.g.LockDir) in, err := cmd.StdinPipe() if err != nil { t.Fatal(err) } out, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatal(err) } waited := false defer func() { in.Close() if !waited { cancel() _ = cmd.Wait() } }() line, err := bufio.NewReader(out).ReadString('\n') if err != nil || line != "locked\n" { t.Fatalf("child readiness: %q %v", line, err) } short, stop := context.WithTimeout(t.Context(), 40*time.Millisecond) defer stop() called, reads := false, f.reads err = f.g.With(short, host, captured, allow, func() error { called = true; return nil }) if !errors.Is(err, context.DeadlineExceeded) || called || f.reads != reads { t.Fatalf("lock admitted: %v %v %d", err, called, f.reads) } if err := in.Close(); err != nil { t.Fatal(err) } err = cmd.Wait() waited = true if err != nil { t.Fatal(err) } if err := f.g.With(ctx, host, captured, allow, func() error { called = true; return nil }); err != nil || !called { t.Fatal(err) } } func TestGuardReadsAfterLock(t *testing.T) { f := newGuardFixture(t) host := f.obs.Host if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } captured, err := Capture(f.g.DataDir, host) if err != nil { t.Fatal(err) } unlock, err := acquire(t.Context(), f.g.LockDir, host) if err != nil { t.Fatal(err) } defer func() { if unlock != nil { unlock() } }() var current atomic.Pointer[hostclaim.Claim] current.Store(f.obs.Claim) reads := make(chan struct{}, 1) f.g.Claims = readerFunc(func(context.Context, string) (hostclaim.Observed, error) { reads <- struct{}{} return hostclaim.Observed{Host: host, Claim: current.Load(), ETag: "fresh"}, nil }) ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() result := make(chan error, 1) go func() { result <- f.g.With(ctx, host, captured, allow, func() error { return errors.New("callback ran") }) }() select { case <-reads: t.Fatal("claim read before lock") case <-time.After(30 * time.Millisecond): } next := *f.obs.Claim holder := *next.Holder holder.Incarnation = "changed" next.Holder = &holder current.Store(&next) unlock() unlock = nil if err := <-result; !errors.Is(err, ErrClaimIncarnation) { t.Fatal(err) } } func TestGuardReleasesAfterErrorAndPanic(t *testing.T) { f := newGuardFixture(t) host := f.obs.Host if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } captured, err := Capture(f.g.DataDir, host) if err != nil { t.Fatal(err) } failure := errors.New("callback failed") if err := f.g.With(t.Context(), host, captured, allow, func() error { return failure }); !errors.Is(err, failure) { t.Fatal(err) } func() { defer func() { if recover() != "panic witness" { t.Error("panic not propagated") } }() _ = f.g.With(t.Context(), host, captured, allow, func() error { panic("panic witness") }) }() ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() if err := f.g.With(ctx, host, captured, allow, func() error { return nil }); err != nil { t.Fatal("leaked flock", err) } if err := f.g.With(ctx, host, captured, allow, nil); !errors.Is(err, ErrInvalidInput) { t.Fatal(err) } if err := f.g.Activate(nil, host, "uk", nil); !errors.Is(err, ErrInvalidInput) { t.Fatal(err) } if err := f.g.Activate(ctx, "../outside", "uk", nil); !errors.Is(err, ErrInvalidHost) { t.Fatal(err) } cancel() if err := f.g.With(ctx, host, captured, allow, func() error { t.Fatal("cancelled callback"); return nil }); !errors.Is(err, context.Canceled) { t.Fatal(err) } } func TestGuardSharedFileModes(t *testing.T) { f := newGuardFixture(t) if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { t.Fatal(err) } for _, path := range []string{ filepath.Join(f.g.LockDir, f.obs.Host+".lock"), filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), } { info, err := os.Stat(path) if err != nil || info.Mode().Perm() != 0660 { t.Fatalf("mode: %v %v", info, err) } } } func TestGuardLockSymlinkRefused(t *testing.T) { for _, directory := range []bool{false, true} { t.Run(fmt.Sprint(directory), func(t *testing.T) { f := newGuardFixture(t) outside := t.TempDir() target := filepath.Join(outside, f.obs.Host+".lock") if err := os.WriteFile(target, []byte("untouched"), 0600); err != nil { t.Fatal(err) } defer unchangedFile(t, target)() link, destination := filepath.Join(f.g.LockDir, f.obs.Host+".lock"), target if directory { if err := os.Remove(f.g.LockDir); err != nil { t.Fatal(err) } link, destination = f.g.LockDir, outside } if err := os.Symlink(destination, link); err != nil { t.Fatal(err) } if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err == nil || f.reads != 0 { t.Fatalf("symlink admitted: %v reads=%d", err, f.reads) } }) } } func TestGuardLockRequiresRegularFile(t *testing.T) { f := newGuardFixture(t) path := filepath.Join(f.g.LockDir, f.obs.Host+".lock") if err := unix.Mkfifo(path, 0600); err != nil { t.Fatal(err) } if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrUnsafeFile) || f.reads != 0 { t.Fatalf("non-regular lock admitted: %v reads=%d", err, f.reads) } info, err := os.Lstat(path) if err != nil || info.Mode().Perm() != 0600 { t.Fatalf("mode changed before type check: %v %v", info, err) } } func TestGuardDirectoriesRequired(t *testing.T) { for _, which := range []string{"lock", "record"} { t.Run(which, func(t *testing.T) { f := newGuardFixture(t) path := f.g.LockDir if which == "record" { path = filepath.Join(f.g.DataDir, "hostguard") } if err := os.Remove(path); err != nil { t.Fatal(err) } if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrNotProvisioned) { t.Fatal(err) } if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { t.Fatal("directory recreated", err) } }) } } func TestGuardRecordSymlinkRefused(t *testing.T) { f := newGuardFixture(t) host := f.obs.Host outside := filepath.Join(t.TempDir(), "record") if err := os.WriteFile(outside, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil { t.Fatal(err) } defer unchangedFile(t, outside)() if err := os.Symlink(outside, filepath.Join(f.g.DataDir, "hostguard", host+".json")); err != nil { t.Fatal(err) } for _, operation := range []func() error{ func() error { return f.g.Activate(t.Context(), host, "uk", nil) }, func() error { return f.g.RetireInstallation(t.Context(), host, allow) }, func() error { return f.g.With(t.Context(), host, Captured{Incarnation: "inc", Stamp: 1}, allow, func() error { t.Fatal("symlink admitted writer") return nil }) }, } { if err := operation(); err == nil || errors.Is(err, ErrNoRecord) { t.Fatal("record symlink not refused", err) } } } func TestGuardDirectorySwap(t *testing.T) { f := newGuardFixture(t) host := f.obs.Host if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } original := filepath.Join(f.g.DataDir, "hostguard") pinned := filepath.Join(f.g.DataDir, "pinned") outside := t.TempDir() target := filepath.Join(outside, host+".json") if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":99,"retired_stamps":[]}`), 0600); err != nil { t.Fatal(err) } defer unchangedFile(t, target)() f.obs.Claim.Holder.Stamp = 2 f.g.Claims = readerFunc(func(context.Context, string) (hostclaim.Observed, error) { // Guard.locked has already opened its record dirfd before the fresh read. if err := os.Rename(original, pinned); err != nil { t.Fatal(err) } if err := os.Symlink(outside, original); err != nil { t.Fatal(err) } return f.obs, nil }) if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal("pinned update failed", err) } fd, err := openDirectory(pinned) if err != nil { t.Fatal(err) } d := &recordDir{fd: fd} defer d.close() r, err := d.read(host) if err != nil || r.ActiveStamp != 2 { t.Fatalf("pinned record: %+v %v", r, err) } entries, err := os.ReadDir(outside) if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" { t.Fatalf("write escaped pinned directory: %v %v", entries, err) } entries, err = os.ReadDir(pinned) if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" { t.Fatalf("temporary file left behind: %v %v", entries, err) } if _, err := Capture(f.g.DataDir, host); err == nil { t.Fatal("new capture followed replacement directory symlink") } }