| 1 | //go:build unix |
| 2 | |
| 3 | package hostguard |
| 4 | |
| 5 | import ( |
| 6 | "bufio" |
| 7 | "context" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "io" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "path/filepath" |
| 14 | "sync/atomic" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/hostclaim" |
| 19 | "golang.org/x/sys/unix" |
| 20 | ) |
| 21 | |
| 22 | func TestGuardProcess(t *testing.T) { |
| 23 | if os.Getenv("CARLOS_HOSTGUARD_TEST_CHILD") == "1" { |
| 24 | unlock, err := acquire(t.Context(), os.Getenv("CARLOS_HOSTGUARD_TEST_LOCKDIR"), "shop.example") |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | defer unlock() |
| 29 | fmt.Fprintln(os.Stdout, "locked") |
| 30 | if _, err := io.Copy(io.Discard, os.Stdin); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | return |
| 34 | } |
| 35 | f := newGuardFixture(t) |
| 36 | host := f.obs.Host |
| 37 | if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | captured, err := Capture(f.g.DataDir, host) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) |
| 45 | defer cancel() |
| 46 | cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGuardProcess$", "-test.count=1") |
| 47 | cmd.Dir = t.TempDir() |
| 48 | cmd.Env = append(os.Environ(), "CARLOS_HOSTGUARD_TEST_CHILD=1", "CARLOS_HOSTGUARD_TEST_LOCKDIR="+f.g.LockDir) |
| 49 | in, err := cmd.StdinPipe() |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | out, err := cmd.StdoutPipe() |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | cmd.Stderr = os.Stderr |
| 58 | if err := cmd.Start(); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | waited := false |
| 62 | defer func() { |
| 63 | in.Close() |
| 64 | if !waited { |
| 65 | cancel() |
| 66 | _ = cmd.Wait() |
| 67 | } |
| 68 | }() |
| 69 | line, err := bufio.NewReader(out).ReadString('\n') |
| 70 | if err != nil || line != "locked\n" { |
| 71 | t.Fatalf("child readiness: %q %v", line, err) |
| 72 | } |
| 73 | short, stop := context.WithTimeout(t.Context(), 40*time.Millisecond) |
| 74 | defer stop() |
| 75 | called, reads := false, f.reads |
| 76 | err = f.g.With(short, host, captured, allow, func() error { called = true; return nil }) |
| 77 | if !errors.Is(err, context.DeadlineExceeded) || called || f.reads != reads { |
| 78 | t.Fatalf("lock admitted: %v %v %d", err, called, f.reads) |
| 79 | } |
| 80 | if err := in.Close(); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | err = cmd.Wait() |
| 84 | waited = true |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if err := f.g.With(ctx, host, captured, allow, func() error { called = true; return nil }); err != nil || !called { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestGuardReadsAfterLock(t *testing.T) { |
| 94 | f := newGuardFixture(t) |
| 95 | host := f.obs.Host |
| 96 | if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | captured, err := Capture(f.g.DataDir, host) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | unlock, err := acquire(t.Context(), f.g.LockDir, host) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | defer func() { |
| 108 | if unlock != nil { |
| 109 | unlock() |
| 110 | } |
| 111 | }() |
| 112 | var current atomic.Pointer[hostclaim.Claim] |
| 113 | current.Store(f.obs.Claim) |
| 114 | reads := make(chan struct{}, 1) |
| 115 | f.g.Claims = readerFunc(func(context.Context, string) (hostclaim.Observed, error) { |
| 116 | reads <- struct{}{} |
| 117 | return hostclaim.Observed{Host: host, Claim: current.Load(), ETag: "fresh"}, nil |
| 118 | }) |
| 119 | ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| 120 | defer cancel() |
| 121 | result := make(chan error, 1) |
| 122 | go func() { |
| 123 | result <- f.g.With(ctx, host, captured, allow, func() error { return errors.New("callback ran") }) |
| 124 | }() |
| 125 | select { |
| 126 | case <-reads: |
| 127 | t.Fatal("claim read before lock") |
| 128 | case <-time.After(30 * time.Millisecond): |
| 129 | } |
| 130 | next := *f.obs.Claim |
| 131 | holder := *next.Holder |
| 132 | holder.Incarnation = "changed" |
| 133 | next.Holder = &holder |
| 134 | current.Store(&next) |
| 135 | unlock() |
| 136 | unlock = nil |
| 137 | if err := <-result; !errors.Is(err, ErrClaimIncarnation) { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestGuardReleasesAfterErrorAndPanic(t *testing.T) { |
| 143 | f := newGuardFixture(t) |
| 144 | host := f.obs.Host |
| 145 | if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | captured, err := Capture(f.g.DataDir, host) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | failure := errors.New("callback failed") |
| 153 | if err := f.g.With(t.Context(), host, captured, allow, func() error { return failure }); !errors.Is(err, failure) { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | func() { |
| 157 | defer func() { |
| 158 | if recover() != "panic witness" { |
| 159 | t.Error("panic not propagated") |
| 160 | } |
| 161 | }() |
| 162 | _ = f.g.With(t.Context(), host, captured, allow, func() error { panic("panic witness") }) |
| 163 | }() |
| 164 | ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| 165 | defer cancel() |
| 166 | if err := f.g.With(ctx, host, captured, allow, func() error { return nil }); err != nil { |
| 167 | t.Fatal("leaked flock", err) |
| 168 | } |
| 169 | if err := f.g.With(ctx, host, captured, allow, nil); !errors.Is(err, ErrInvalidInput) { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | if err := f.g.Activate(nil, host, "uk", nil); !errors.Is(err, ErrInvalidInput) { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | if err := f.g.Activate(ctx, "../outside", "uk", nil); !errors.Is(err, ErrInvalidHost) { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | cancel() |
| 179 | if err := f.g.With(ctx, host, captured, allow, func() error { t.Fatal("cancelled callback"); return nil }); !errors.Is(err, context.Canceled) { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestGuardSharedFileModes(t *testing.T) { |
| 185 | f := newGuardFixture(t) |
| 186 | if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | for _, path := range []string{ |
| 190 | filepath.Join(f.g.LockDir, f.obs.Host+".lock"), |
| 191 | filepath.Join(f.g.DataDir, "hostguard", f.obs.Host+".json"), |
| 192 | } { |
| 193 | info, err := os.Stat(path) |
| 194 | if err != nil || info.Mode().Perm() != 0660 { |
| 195 | t.Fatalf("mode: %v %v", info, err) |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestGuardLockSymlinkRefused(t *testing.T) { |
| 201 | for _, directory := range []bool{false, true} { |
| 202 | t.Run(fmt.Sprint(directory), func(t *testing.T) { |
| 203 | f := newGuardFixture(t) |
| 204 | outside := t.TempDir() |
| 205 | target := filepath.Join(outside, f.obs.Host+".lock") |
| 206 | if err := os.WriteFile(target, []byte("untouched"), 0600); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | defer unchangedFile(t, target)() |
| 210 | link, destination := filepath.Join(f.g.LockDir, f.obs.Host+".lock"), target |
| 211 | if directory { |
| 212 | if err := os.Remove(f.g.LockDir); err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | link, destination = f.g.LockDir, outside |
| 216 | } |
| 217 | if err := os.Symlink(destination, link); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err == nil || f.reads != 0 { |
| 221 | t.Fatalf("symlink admitted: %v reads=%d", err, f.reads) |
| 222 | } |
| 223 | }) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func TestGuardLockRequiresRegularFile(t *testing.T) { |
| 228 | f := newGuardFixture(t) |
| 229 | path := filepath.Join(f.g.LockDir, f.obs.Host+".lock") |
| 230 | if err := unix.Mkfifo(path, 0600); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrUnsafeFile) || f.reads != 0 { |
| 234 | t.Fatalf("non-regular lock admitted: %v reads=%d", err, f.reads) |
| 235 | } |
| 236 | info, err := os.Lstat(path) |
| 237 | if err != nil || info.Mode().Perm() != 0600 { |
| 238 | t.Fatalf("mode changed before type check: %v %v", info, err) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | func TestGuardDirectoriesRequired(t *testing.T) { |
| 243 | for _, which := range []string{"lock", "record"} { |
| 244 | t.Run(which, func(t *testing.T) { |
| 245 | f := newGuardFixture(t) |
| 246 | path := f.g.LockDir |
| 247 | if which == "record" { |
| 248 | path = filepath.Join(f.g.DataDir, "hostguard") |
| 249 | } |
| 250 | if err := os.Remove(path); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); !errors.Is(err, ErrNotProvisioned) { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { |
| 257 | t.Fatal("directory recreated", err) |
| 258 | } |
| 259 | }) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | func TestGuardRecordSymlinkRefused(t *testing.T) { |
| 264 | f := newGuardFixture(t) |
| 265 | host := f.obs.Host |
| 266 | outside := filepath.Join(t.TempDir(), "record") |
| 267 | if err := os.WriteFile(outside, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | defer unchangedFile(t, outside)() |
| 271 | if err := os.Symlink(outside, filepath.Join(f.g.DataDir, "hostguard", host+".json")); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | for _, operation := range []func() error{ |
| 275 | func() error { return f.g.Activate(t.Context(), host, "uk", nil) }, |
| 276 | func() error { return f.g.RetireInstallation(t.Context(), host, allow) }, |
| 277 | func() error { |
| 278 | return f.g.With(t.Context(), host, Captured{Incarnation: "inc", Stamp: 1}, allow, func() error { |
| 279 | t.Fatal("symlink admitted writer") |
| 280 | return nil |
| 281 | }) |
| 282 | }, |
| 283 | } { |
| 284 | if err := operation(); err == nil || errors.Is(err, ErrNoRecord) { |
| 285 | t.Fatal("record symlink not refused", err) |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | func TestGuardDirectorySwap(t *testing.T) { |
| 291 | f := newGuardFixture(t) |
| 292 | host := f.obs.Host |
| 293 | if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | original := filepath.Join(f.g.DataDir, "hostguard") |
| 297 | pinned := filepath.Join(f.g.DataDir, "pinned") |
| 298 | outside := t.TempDir() |
| 299 | target := filepath.Join(outside, host+".json") |
| 300 | if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":99,"retired_stamps":[]}`), 0600); err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | defer unchangedFile(t, target)() |
| 304 | f.obs.Claim.Holder.Stamp = 2 |
| 305 | f.g.Claims = readerFunc(func(context.Context, string) (hostclaim.Observed, error) { |
| 306 | // Guard.locked has already opened its record dirfd before the fresh read. |
| 307 | if err := os.Rename(original, pinned); err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | if err := os.Symlink(outside, original); err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | return f.obs, nil |
| 314 | }) |
| 315 | if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| 316 | t.Fatal("pinned update failed", err) |
| 317 | } |
| 318 | fd, err := openDirectory(pinned) |
| 319 | if err != nil { |
| 320 | t.Fatal(err) |
| 321 | } |
| 322 | d := &recordDir{fd: fd} |
| 323 | defer d.close() |
| 324 | r, err := d.read(host) |
| 325 | if err != nil || r.ActiveStamp != 2 { |
| 326 | t.Fatalf("pinned record: %+v %v", r, err) |
| 327 | } |
| 328 | entries, err := os.ReadDir(outside) |
| 329 | if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" { |
| 330 | t.Fatalf("write escaped pinned directory: %v %v", entries, err) |
| 331 | } |
| 332 | entries, err = os.ReadDir(pinned) |
| 333 | if err != nil || len(entries) != 1 || entries[0].Name() != host+".json" { |
| 334 | t.Fatalf("temporary file left behind: %v %v", entries, err) |
| 335 | } |
| 336 | if _, err := Capture(f.g.DataDir, host); err == nil { |
| 337 | t.Fatal("new capture followed replacement directory symlink") |
| 338 | } |
| 339 | } |
| 340 | |