| new file mode 100644 |
| index 000000000..e8b5deff5 |
| --- /dev/null |
| +++ b/internal/hostguard/flock_unix_test.go |
| @@ -0,0 +1,339 @@ |
| +//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") |
| + } |
| +} |