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 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14
15 "golang.org/x/sys/unix"
16)
17
18func recordFixture(t *testing.T) string {
19 t.Helper()
20 dir := t.TempDir()
21 if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil {
22 t.Fatal(err)
23 }
24 return dir
25}
26
27func TestCaptureStrict(t *testing.T) {
28 dir := recordFixture(t)
29 host := "shop.example"
30 if _, err := Capture(dir, host); !errors.Is(err, ErrNoRecord) {
31 t.Fatal(err)
32 }
33 if _, err := Capture(dir, "../outside"); !errors.Is(err, ErrInvalidHost) {
34 t.Fatal(err)
35 }
36 good := `{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":3,"retired_stamps":[1,2]}`
37 for _, tc := range []struct {
38 name, body string
39 valid bool
40 }{
41 {"good", good, true},
42 {"unknown", strings.Replace(good, `"v":1`, `"v":1,"future":true`, 1), false},
43 {"trailing", good + `{}`, false},
44 {"trailing-garbage", good + `!`, false},
45 {"version", strings.Replace(good, `"v":1`, `"v":2`, 1), false},
46 {"host", strings.Replace(good, "shop.example", "other.example", 1), false},
47 {"incarnation", strings.Replace(good, `"inc"`, `""`, 1), false},
48 {"active-retired", strings.Replace(good, `[1,2]`, `[1,3]`, 1), false},
49 {"zero-retired", strings.Replace(good, `[1,2]`, `[0,2]`, 1), false},
50 {"duplicate", strings.Replace(good, `[1,2]`, `[1,1]`, 1), false},
51 {"unordered", strings.Replace(good, `[1,2]`, `[2,1]`, 1), false},
52 {"null", `null`, false},
53 {"truncated", good[:len(good)-1], false},
54 {"negative", strings.Replace(good, `"active_stamp":3`, `"active_stamp":-1`, 1), false},
55 } {
56 t.Run(tc.name, func(t *testing.T) {
57 if err := os.WriteFile(filepath.Join(dir, "hostguard", host+".json"), []byte(tc.body), 0660); err != nil {
58 t.Fatal(err)
59 }
60 got, err := Capture(dir, host)
61 if tc.valid {
62 if err != nil || got != (Captured{Incarnation: "inc", Stamp: 3}) {
63 t.Fatalf("%+v %v", got, err)
64 }
65 } else if !errors.Is(err, ErrMalformedRecord) {
66 t.Fatal(err)
67 }
68 })
69 }
70}
71
72func TestRecordAtomicRename(t *testing.T) {
73 dir, host := recordFixture(t), "shop.example"
74 r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 1, RetiredStamps: []uint64{}}
75 if err := writeRecord(dir, r); err != nil {
76 t.Fatal(err)
77 }
78 want := errors.New("rename failed")
79 r.ActiveStamp = 2
80 d, err := openRecordDir(dir)
81 if err != nil {
82 t.Fatal(err)
83 }
84 defer d.close()
85 err = d.writeUsing(r, ".rename-test", func(srcFD int, src string, dstFD int, dst string) error {
86 if srcFD != d.fd || dstFD != d.fd || src != ".rename-test" || dst != host+".json" {
87 t.Fatal("rename escaped pinned directory")
88 }
89 fd, e := unix.Openat(srcFD, src, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
90 if e != nil {
91 t.Fatal(e)
92 }
93 f := os.NewFile(uintptr(fd), src)
94 b, e := io.ReadAll(f)
95 f.Close()
96 if e != nil || !strings.Contains(string(b), `"active_stamp":2`) {
97 t.Fatalf("temporary file: %s %v", b, e)
98 }
99 return want
100 })
101 if !errors.Is(err, want) {
102 t.Fatal(err)
103 }
104 got, err := Capture(dir, host)
105 if err != nil || got.Stamp != 1 {
106 t.Fatalf("old record: %+v %v", got, err)
107 }
108 files, err := os.ReadDir(filepath.Join(dir, "hostguard"))
109 if err != nil || len(files) != 1 || files[0].Name() != host+".json" {
110 t.Fatalf("leftovers: %v %v", files, err)
111 }
112
113 var wg sync.WaitGroup
114 errs := make(chan error, 1)
115 done := make(chan struct{})
116 wg.Add(1)
117 go func() {
118 defer wg.Done()
119 for {
120 select {
121 case <-done:
122 return
123 default:
124 }
125 c, e := Capture(dir, host)
126 if e != nil || c.Incarnation != "inc" || c.Stamp == 0 {
127 select {
128 case errs <- fmt.Errorf("torn capture: %+v %v", c, e):
129 default:
130 }
131 return
132 }
133 }
134 }()
135 for i := uint64(2); i <= 40; i++ {
136 r.ActiveStamp = i
137 if err := writeRecord(dir, r); err != nil {
138 close(done)
139 wg.Wait()
140 t.Fatal(err)
141 }
142 }
143 close(done)
144 wg.Wait()
145 select {
146 case err := <-errs:
147 t.Fatal(err)
148 default:
149 }
150}
151
152func TestRecordRejectsOversizedHistoryAndReadError(t *testing.T) {
153 r := record{V: 1, Host: "shop.example", Incarnation: "inc"}
154 for i := uint64(1); i <= 33; i++ {
155 r.RetiredStamps = append(r.RetiredStamps, i)
156 }
157 if !errors.Is(r.validate(r.Host), ErrMalformedRecord) {
158 t.Fatal("accepted 33 stamps")
159 }
160 dir := t.TempDir()
161 if err := os.MkdirAll(filepath.Join(dir, "hostguard", r.Host+".json"), 0700); err != nil {
162 t.Fatal(err)
163 }
164 if _, err := Capture(dir, r.Host); err == nil || errors.Is(err, ErrNoRecord) {
165 t.Fatalf("directory became absence: %v", err)
166 }
167}
168
169func unchangedFile(t *testing.T, path string) func() {
170 t.Helper()
171 before, err := os.Stat(path)
172 if err != nil {
173 t.Fatal(err)
174 }
175 body, err := os.ReadFile(path)
176 if err != nil {
177 t.Fatal(err)
178 }
179 return func() {
180 t.Helper()
181 after, err := os.Stat(path)
182 if err != nil {
183 t.Fatal(err)
184 }
185 got, err := os.ReadFile(path)
186 if err != nil || string(got) != string(body) || before.Mode() != after.Mode() || !os.SameFile(before, after) {
187 t.Fatalf("target changed: before=%v after=%v body=%q err=%v", before, after, got, err)
188 }
189 }
190}
191
192func TestRecordNotProvisioned(t *testing.T) {
193 dir := t.TempDir()
194 r := record{V: 1, Host: "shop.example", Incarnation: "inc", ActiveStamp: 1}
195 if _, err := Capture(dir, r.Host); !errors.Is(err, ErrNotProvisioned) || errors.Is(err, ErrNoRecord) {
196 t.Fatal(err)
197 }
198 if err := writeRecord(dir, r); !errors.Is(err, ErrNotProvisioned) {
199 t.Fatal(err)
200 }
201 if _, err := os.Lstat(filepath.Join(dir, "hostguard")); !errors.Is(err, os.ErrNotExist) {
202 t.Fatalf("library created directory: %v", err)
203 }
204}
205
206func TestRecordSymlinksRefused(t *testing.T) {
207 for _, kind := range []string{"directory", "record", "temp"} {
208 t.Run(kind, func(t *testing.T) {
209 dir := recordFixture(t)
210 host := "shop.example"
211 r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 2}
212 outside := t.TempDir()
213 target := filepath.Join(outside, host+".json")
214 if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil {
215 t.Fatal(err)
216 }
217 defer unchangedFile(t, target)()
218 guardDir := filepath.Join(dir, "hostguard")
219 link, destination := filepath.Join(guardDir, host+".json"), target
220 if kind == "directory" {
221 if err := os.Remove(guardDir); err != nil {
222 t.Fatal(err)
223 }
224 link, destination = guardDir, outside
225 } else if kind == "temp" {
226 link = filepath.Join(guardDir, ".fixed-temp")
227 }
228 if err := os.Symlink(destination, link); err != nil {
229 t.Fatal(err)
230 }
231 if kind == "temp" {
232 d, err := openRecordDir(dir)
233 if err != nil {
234 t.Fatal(err)
235 }
236 defer d.close()
237 if err := d.writeUsing(r, ".fixed-temp", unix.Renameat); err == nil {
238 t.Fatal("accepted temporary symlink")
239 }
240 if _, err := d.read(host); !errors.Is(err, ErrNoRecord) {
241 t.Fatal("published record despite refused temporary file", err)
242 }
243 } else {
244 if _, err := Capture(dir, host); err == nil || errors.Is(err, ErrNoRecord) {
245 t.Fatal("capture followed symlink", err)
246 }
247 if err := writeRecord(dir, r); err == nil {
248 t.Fatal("write accepted symlink")
249 }
250 }
251 if got, err := os.Readlink(link); err != nil || got != destination {
252 t.Fatalf("symlink removed or replaced: %q %v", got, err)
253 }
254 })
255 }
256}
257