carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build unix
2
3package hostguard
4
5import (
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
22func 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
93func 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
142func 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
184func 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
200func 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
227func 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
242func 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
263func 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
290func 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