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 linux
2
3package layout
4
5import (
6 "errors"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "syscall"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/instancefs"
16 "github.com/carlosframework/platform/internal/registry"
17)
18
19func TestPreflightRefusesAConfigEnvOwnedByAnotherRoute(t *testing.T) {
20 p, _ := PlanFor("/data", "/run/carlos", "x.example.com", ModeNested)
21 routes := []registry.Route{{Host: "x.example.com"}, {Host: "x.example.com.config"}}
22 err := Preflight(p, routes)
23 if err == nil || !strings.Contains(err.Error(), "x.example.com.config") {
24 t.Fatalf("ambiguous config-env accepted: %v", err)
25 }
26 if err := Preflight(p, []registry.Route{{Host: "x.example.com"}}); err != nil {
27 t.Fatalf("unambiguous plan refused: %v", err)
28 }
29 // Reverting applies the same table: a flat destination another route owns is a refusal.
30 r, _ := PlanFor("/data", "/run/carlos", "x.example.com", ModeFlat)
31 if err := Preflight(r, routes); err == nil {
32 t.Fatal("revert onto another route's file accepted")
33 }
34 // The other direction of the same rule: a host named <owner>.config
35 // has an env file that is also <owner>'s rendered bundle.
36 c, _ := PlanFor("/data", "/run/carlos", "x.example.com.config", ModeNested)
37 if err := Preflight(c, routes); err == nil || !strings.Contains(err.Error(), "x.example.com") {
38 t.Fatalf("an env file that is another route's bundle accepted: %v", err)
39 }
40}
41
42// The same collision through the sidecar env: a route named <h>-sidecar
43// owns <h>-sidecar.env as its instance env, and h's plan would move it as
44// h's sidecar env.
45func TestPreflightRefusesASidecarEnvOwnedByAnotherRoute(t *testing.T) {
46 routes := []registry.Route{{Host: "x.example.com"}, {Host: "x.example.com-sidecar"}}
47 p, _ := PlanFor("/data", "/run/carlos", "x.example.com", ModeNested)
48 err := Preflight(p, routes)
49 if err == nil || !strings.Contains(err.Error(), "x.example.com-sidecar.env") || !strings.Contains(err.Error(), "x.example.com-sidecar (its instance env)") {
50 t.Fatalf("ambiguous sidecar env accepted: %v", err)
51 }
52 if err := Preflight(p, []registry.Route{{Host: "x.example.com"}}); err != nil {
53 t.Fatalf("unambiguous plan refused: %v", err)
54 }
55 r, _ := PlanFor("/data", "/run/carlos", "x.example.com", ModeFlat)
56 if err := Preflight(r, routes); err == nil {
57 t.Fatal("revert onto another route's sidecar env accepted")
58 }
59 // The other direction: the host named <owner>-sidecar has an env file
60 // that is also <owner>'s sidecar env.
61 c, _ := PlanFor("/data", "/run/carlos", "x.example.com-sidecar", ModeNested)
62 if err := Preflight(c, routes); err == nil || !strings.Contains(err.Error(), "x.example.com (its sidecar env)") {
63 t.Fatalf("an env file that is another route's sidecar env accepted: %v", err)
64 }
65}
66
67// A nested socket's control twin must fit a unix socket address. The
68// socket dir here is long enough that the nested address overflows it.
69func TestPreflightRefusesANestedSocketTooLongForItsControlTwin(t *testing.T) {
70 long := "/" + strings.Repeat("s", 60)
71 host := strings.Repeat("h", 30) + ".example.com"
72 p, _ := PlanFor("/data", long, host, ModeNested)
73 want := len(p.To.Socket) + len(".control")
74 err := Preflight(p, []registry.Route{{Host: host}})
75 if err == nil || !strings.Contains(err.Error(), "107") || !strings.Contains(err.Error(), strconv.Itoa(want)) {
76 t.Fatalf("overlong socket accepted or not measured: %v (want %d named)", err, want)
77 }
78 // Reverting to flat does not create the nested address, so it is not
79 // measured.
80 r, _ := PlanFor("/data", long, host, ModeFlat)
81 if err := Preflight(r, []registry.Route{{Host: host}}); err != nil {
82 t.Fatalf("revert refused over a nested length: %v", err)
83 }
84}
85
86func TestDestinationCheckRefusesTwoDatabasesAndMovesEnvAside(t *testing.T) {
87 dd := t.TempDir()
88 p, _ := PlanFor(dd, filepath.Join(dd, "run"), "h.example.com", ModeNested)
89 if err := os.MkdirAll(rawInstanceDir(dd, "h.example.com"), 0o700); err != nil {
90 t.Fatal(err)
91 }
92 if err := os.WriteFile(p.Artifacts[0].Dst, []byte("x"), 0o600); err != nil {
93 t.Fatal(err)
94 }
95 if _, err := DestinationCheck(p, time.Now); err == nil || !strings.Contains(err.Error(), "two databases") {
96 t.Fatalf("two databases not refused: %v", err)
97 }
98 os.Remove(p.Artifacts[0].Dst)
99 if err := os.WriteFile(p.Artifacts[3].Dst, []byte("fresh env minted by an older binary"), 0o600); err != nil {
100 t.Fatal(err)
101 }
102 aside, err := DestinationCheck(p, func() time.Time { return time.Unix(1700000000, 0) })
103 if err != nil {
104 t.Fatal(err)
105 }
106 if len(aside) != 1 || !strings.HasSuffix(aside[0], ".env.aside-1700000000") {
107 t.Fatalf("aside = %v", aside)
108 }
109 if _, err := os.Stat(p.Artifacts[3].Dst); !os.IsNotExist(err) {
110 t.Error("env destination still occupied")
111 }
112 if b, err := os.ReadFile(aside[0]); err != nil || string(b) != "fresh env minted by an older binary" {
113 t.Errorf("aside copy: %q %v", b, err)
114 }
115}
116
117func artifactNamed(t *testing.T, p *Plan, name string) Artifact {
118 t.Helper()
119 for _, a := range p.Artifacts {
120 if a.Name == name {
121 return a
122 }
123 }
124 t.Fatalf("no artifact %q", name)
125 return Artifact{}
126}
127
128// A litestream shadow already at the destination is a second replication
129// state for one database: refused like a second database. A quarantine
130// already there is moved aside like an env file, and the rendered bundle
131// is cleared so the source takes its name.
132func TestDestinationCheckShadowRefusesCorruptAsideConfigCleared(t *testing.T) {
133 dd := t.TempDir()
134 host := "h.example.com"
135 p, _ := PlanFor(dd, filepath.Join(dd, "run"), host, ModeNested)
136 if err := os.MkdirAll(rawInstanceDir(dd, host), 0o700); err != nil {
137 t.Fatal(err)
138 }
139 shadow := artifactNamed(t, p, "litestream-shadow").Dst
140 if err := os.Mkdir(shadow, 0o700); err != nil {
141 t.Fatal(err)
142 }
143 if _, err := DestinationCheck(p, time.Now); err == nil || !strings.Contains(err.Error(), "two replication states") {
144 t.Fatalf("destination shadow not refused: %v", err)
145 }
146 os.Remove(shadow)
147 corrupt := artifactNamed(t, p, "corrupt").Dst
148 config := artifactNamed(t, p, "config-env").Dst
149 for _, f := range []string{corrupt, config} {
150 if err := os.WriteFile(f, []byte("old"), 0o600); err != nil {
151 t.Fatal(err)
152 }
153 }
154 aside, err := DestinationCheck(p, func() time.Time { return time.Unix(1700000000, 0) })
155 if err != nil {
156 t.Fatal(err)
157 }
158 // The kept copy lives under the pass's own aside directory, outside
159 // the instance tree (review F3).
160 if len(aside) != 1 || aside[0] != filepath.Join(dd, "layout", "aside", host, filepath.Base(corrupt)+".aside-1700000000") {
161 t.Fatalf("aside = %v", aside)
162 }
163 for _, f := range []string{corrupt, config} {
164 if _, err := os.Lstat(f); !errors.Is(err, os.ErrNotExist) {
165 t.Errorf("%s still occupied", f)
166 }
167 }
168}
169
170// The reverse direction checks the flat side the same way.
171func TestDestinationCheckRefusesADatabaseAtTheFlatPathOnRevert(t *testing.T) {
172 dd := t.TempDir()
173 p, _ := PlanFor(dd, filepath.Join(dd, "run"), "h.example.com", ModeFlat)
174 if err := os.WriteFile(filepath.Join(dd, "h.example.com.db-wal"), []byte("w"), 0o600); err != nil {
175 t.Fatal(err)
176 }
177 if _, err := DestinationCheck(p, time.Now); err == nil || !strings.Contains(err.Error(), "two databases") {
178 t.Fatalf("a flat wal on revert not refused: %v", err)
179 }
180}
181
182func TestEnsureDirsCreatesTheRawDirectoriesWithTheRightModes(t *testing.T) {
183 dd, rd := t.TempDir(), t.TempDir()
184 if err := EnsureDirs(dd, rd, "h.example.com", -1); err != nil {
185 t.Fatal(err)
186 }
187 fi, err := os.Lstat(rawInstanceDir(dd, "h.example.com"))
188 if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o700 {
189 t.Errorf("data dir: %v %v", fi, err)
190 }
191 fi, err = os.Lstat(rawRunDir(rd, "h.example.com"))
192 if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o750 {
193 t.Errorf("run dir: %v %v", fi, err)
194 }
195 // Idempotent: the pass calls it on every move.
196 if err := EnsureDirs(dd, rd, "h.example.com", -1); err != nil {
197 t.Fatalf("second call: %v", err)
198 }
199}
200
201func TestEnsureDirsRefusesASymlinkAtTheInstanceDirectory(t *testing.T) {
202 dd, rd, elsewhere := t.TempDir(), t.TempDir(), t.TempDir()
203 host := "h.example.com"
204 raw := rawInstanceDir(dd, host)
205 if err := os.MkdirAll(filepath.Dir(raw), 0o700); err != nil {
206 t.Fatal(err)
207 }
208 if err := os.Symlink(elsewhere, raw); err != nil {
209 t.Fatal(err)
210 }
211 if err := EnsureDirs(dd, rd, host, -1); !errors.Is(err, instancefs.ErrSymlink) {
212 t.Fatalf("err = %v, want ErrSymlink", err)
213 }
214}
215
216// supplementaryGroup is a group the test runner is in other than its
217// effective one, so a directory can be given a group that inheritance
218// alone explains. The runner is not assumed to be root.
219func supplementaryGroup(t *testing.T) int {
220 t.Helper()
221 groups, err := os.Getgroups()
222 if err != nil {
223 t.Fatal(err)
224 }
225 probe := t.TempDir()
226 for _, g := range groups {
227 // A group the runner is listed in can still be unusable: inside a
228 // user namespace an unmapped gid is refused with EINVAL.
229 if g != os.Getegid() && os.Chown(probe, -1, g) == nil {
230 return g
231 }
232 }
233 t.Skip("the test runner is in no usable supplementary group, so a setgid parent with a group other than its own cannot be built")
234 return -1
235}
236
237func gidOf(t *testing.T, path string) int {
238 t.Helper()
239 fi, err := os.Lstat(path)
240 if err != nil {
241 t.Fatal(err)
242 }
243 return int(fi.Sys().(*syscall.Stat_t).Gid)
244}
245
246// runParentFixture makes <rd>/.instances with group gid and mode, the way
247// tmpfiles leaves it on a box, and the data parent 0700.
248func runParentFixture(t *testing.T, dd, rd string, gid int, mode os.FileMode) string {
249 t.Helper()
250 host := "h.example.com"
251 parent := filepath.Dir(rawRunDir(rd, host))
252 if err := os.Mkdir(parent, 0o700); err != nil {
253 t.Fatal(err)
254 }
255 if err := os.Chown(parent, -1, gid); err != nil {
256 t.Fatal(err)
257 }
258 if err := os.Chmod(parent, mode); err != nil {
259 t.Fatal(err)
260 }
261 if err := os.Mkdir(filepath.Dir(rawInstanceDir(dd, host)), 0o700); err != nil {
262 t.Fatal(err)
263 }
264 return parent
265}
266
267// The pass runs as carlos, which is not in carlos-sockets, so it can
268// neither chown into that group nor chmod a directory of it without the
269// kernel clearing setgid. The instance's run directory gets the group and
270// setgid by inheritance alone, and the tmpfiles-owned parents are never
271// touched. The parent's mode here is deliberately not the 2750 tmpfiles
272// gives it, so any chmod of it shows.
273func TestEnsureDirsInheritsTheSocketsGroupAndLeavesTheParentsAlone(t *testing.T) {
274 g := supplementaryGroup(t)
275 dd, rd := t.TempDir(), t.TempDir()
276 host := "h.example.com"
277 runParent := runParentFixture(t, dd, rd, g, 0o770|os.ModeSetgid)
278 dataParent := filepath.Dir(rawInstanceDir(dd, host))
279 if err := os.Chmod(dataParent, 0o710); err != nil {
280 t.Fatal(err)
281 }
282 before := map[string]os.FileMode{}
283 for _, d := range []string{runParent, dataParent} {
284 fi, err := os.Lstat(d)
285 if err != nil {
286 t.Fatal(err)
287 }
288 before[d] = fi.Mode()
289 }
290 for i := 0; i < 2; i++ {
291 if err := EnsureDirs(dd, rd, host, g); err != nil {
292 t.Fatalf("call %d: %v", i+1, err)
293 }
294 }
295 run := rawRunDir(rd, host)
296 fi, err := os.Lstat(run)
297 if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o750 || fi.Mode()&os.ModeSetgid == 0 {
298 t.Fatalf("run dir: %v %v, want a 2750 directory", fi, err)
299 }
300 if got := gidOf(t, run); got != g {
301 t.Errorf("run dir group = %d, want the inherited %d", got, g)
302 }
303 for d, mode := range before {
304 fi, err := os.Lstat(d)
305 if err != nil {
306 t.Fatal(err)
307 }
308 if fi.Mode() != mode {
309 t.Errorf("%s mode changed from %v to %v; the pass must never chmod a tmpfiles-owned parent", d, mode, fi.Mode())
310 }
311 }
312 if got := gidOf(t, runParent); got != g {
313 t.Errorf("run parent group changed to %d", got)
314 }
315}
316
317// A run parent that has lost setgid, or carries the wrong group, would
318// hand every new instance directory the wrong group. The pass refuses,
319// names the parent, repairs nothing and creates nothing.
320func TestEnsureDirsRefusesARunParentWithoutSetgidOrTheSocketsGroup(t *testing.T) {
321 g := supplementaryGroup(t)
322 for _, tc := range []struct {
323 name string
324 gid int
325 mode os.FileMode
326 }{
327 {"no setgid", g, 0o750},
328 {"wrong group", os.Getegid(), 0o750 | os.ModeSetgid},
329 } {
330 t.Run(tc.name, func(t *testing.T) {
331 dd, rd := t.TempDir(), t.TempDir()
332 host := "h.example.com"
333 parent := runParentFixture(t, dd, rd, tc.gid, tc.mode)
334 err := EnsureDirs(dd, rd, host, g)
335 if err == nil || !strings.Contains(err.Error(), parent) || !strings.Contains(err.Error(), "tmpfiles should restore it") {
336 t.Fatalf("err = %v, want a refusal naming %s", err, parent)
337 }
338 for _, d := range []string{rawInstanceDir(dd, host), rawRunDir(rd, host)} {
339 if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
340 t.Errorf("%s was created by a refused call", d)
341 }
342 }
343 fi, _ := os.Lstat(parent)
344 if fi.Mode() != os.ModeDir|tc.mode || gidOf(t, parent) != tc.gid {
345 t.Errorf("parent repaired to %v group %d", fi.Mode(), gidOf(t, parent))
346 }
347 })
348 }
349}
350
351// An instance directory already there with the wrong group, without the
352// setgid its parent has, or with the wrong mode is refused as it stands:
353// changing it is exactly the chmod or chown the pass cannot do safely.
354func TestEnsureDirsRefusesAnExistingInstanceDirectoryItCannotVouchFor(t *testing.T) {
355 g := supplementaryGroup(t)
356 host := "h.example.com"
357 for _, tc := range []struct {
358 name string
359 setup func(t *testing.T, dd, rd string) string
360 }{
361 {"run dir with the wrong group", func(t *testing.T, dd, rd string) string {
362 d := rawRunDir(rd, host)
363 if err := os.Mkdir(d, 0o750); err != nil {
364 t.Fatal(err)
365 }
366 if err := os.Chown(d, -1, os.Getegid()); err != nil {
367 t.Fatal(err)
368 }
369 if err := os.Chmod(d, 0o750|os.ModeSetgid); err != nil {
370 t.Fatal(err)
371 }
372 return d
373 }},
374 {"run dir without setgid", func(t *testing.T, dd, rd string) string {
375 d := rawRunDir(rd, host)
376 if err := os.Mkdir(d, 0o750); err != nil {
377 t.Fatal(err)
378 }
379 if err := os.Chmod(d, 0o750); err != nil {
380 t.Fatal(err)
381 }
382 return d
383 }},
384 {"data dir opened up", func(t *testing.T, dd, rd string) string {
385 d := rawInstanceDir(dd, host)
386 if err := os.Mkdir(d, 0o700); err != nil {
387 t.Fatal(err)
388 }
389 if err := os.Chmod(d, 0o755); err != nil {
390 t.Fatal(err)
391 }
392 return d
393 }},
394 } {
395 t.Run(tc.name, func(t *testing.T) {
396 dd, rd := t.TempDir(), t.TempDir()
397 runParentFixture(t, dd, rd, g, 0o750|os.ModeSetgid)
398 d := tc.setup(t, dd, rd)
399 before, err := os.Lstat(d)
400 if err != nil {
401 t.Fatal(err)
402 }
403 gid := gidOf(t, d)
404 if err := EnsureDirs(dd, rd, host, g); err == nil || !strings.Contains(err.Error(), d) {
405 t.Fatalf("err = %v, want a refusal naming %s", err, d)
406 }
407 after, _ := os.Lstat(d)
408 if after.Mode() != before.Mode() || gidOf(t, d) != gid {
409 t.Errorf("%s changed from %v group %d to %v group %d", d, before.Mode(), gid, after.Mode(), gidOf(t, d))
410 }
411 })
412 }
413}
414
415// Review F1, amended by the landing follow-up: a flat tenant can make a
416// sibling's future directory ahead of the pass and hard-link the box's
417// credentials into it. The entry is quarantined into the host's aside
418// directory, outside every namespace, and the migration goes on: a
419// refusal would have let one tenant hold a sibling down with a touch.
420// The link itself moves as an entry, so the credentials file keeps its
421// own name and the quarantined name is harmless where it now sits.
422func TestDestinationCheckQuarantinesEntriesThePlanDoesNotName(t *testing.T) {
423 dd := t.TempDir()
424 host := "h.example.com"
425 p, _ := PlanFor(dd, filepath.Join(dd, "run"), host, ModeNested)
426 raw := rawInstanceDir(dd, host)
427 if err := os.MkdirAll(raw, 0o700); err != nil {
428 t.Fatal(err)
429 }
430 secret := filepath.Join(dd, "litestream.env")
431 if err := os.WriteFile(secret, []byte("AWS_SECRET_ACCESS_KEY=x\n"), 0o600); err != nil {
432 t.Fatal(err)
433 }
434 if err := os.Link(secret, filepath.Join(raw, "creds")); err != nil {
435 t.Skipf("hard links unavailable here: %v", err)
436 }
437 if err := os.Mkdir(filepath.Join(raw, "stash"), 0o700); err != nil {
438 t.Fatal(err)
439 }
440 if err := os.Symlink(secret, filepath.Join(raw, "link")); err != nil {
441 t.Fatal(err)
442 }
443 aside, err := DestinationCheck(p, time.Now)
444 if err != nil {
445 t.Fatalf("planted entries not quarantined: %v", err)
446 }
447 if len(aside) != 3 {
448 t.Fatalf("aside = %v, want the three planted entries", aside)
449 }
450 for _, a := range aside {
451 if !strings.HasPrefix(a, filepath.Join(dd, "layout", "aside", host)+"/") || !strings.Contains(filepath.Base(a), ".q-") {
452 t.Errorf("quarantined to %s, want under layout/aside/%s with a .q- suffix", a, host)
453 }
454 }
455 entries, err := os.ReadDir(raw)
456 if err != nil || len(entries) != 0 {
457 t.Errorf("instance directory still holds %v (%v)", entries, err)
458 }
459 // The credentials file is untouched and still has its own name.
460 if fi, err := os.Lstat(secret); err != nil {
461 t.Errorf("litestream.env: %v", err)
462 } else if n, _ := linkCount(fi); n != 2 {
463 t.Errorf("litestream.env has %d names; the quarantined link should still be one of them", n)
464 }
465 // The revert direction's destination is the flat directory, where the
466 // rule cannot apply; nothing there is touched for being unplanned.
467 rev, _ := PlanFor(dd, filepath.Join(dd, "run"), host, ModeFlat)
468 if moved, err := DestinationCheck(rev, time.Now); err != nil || len(moved) != 0 {
469 t.Fatalf("revert touched the flat directory's other files: %v %v", moved, err)
470 }
471}
472
473// A planted entry at a PLANNED name is handled by that name's rule, and
474// the aside copy ends up outside the instance tree (review F3), where no
475// instance namespace reaches: a tenant must not read what a previous
476// owner of its hostname, or a sibling, left at that name.
477func TestDestinationCheckKeepsAsidesOutsideTheInstanceTree(t *testing.T) {
478 dd := t.TempDir()
479 host := "h.example.com"
480 p, _ := PlanFor(dd, filepath.Join(dd, "run"), host, ModeNested)
481 if err := os.MkdirAll(rawInstanceDir(dd, host), 0o700); err != nil {
482 t.Fatal(err)
483 }
484 env := artifactNamed(t, p, "env").Dst
485 if err := os.WriteFile(env, []byte("CARLOS_ADMIN_TOKEN=previous-owner\n"), 0o600); err != nil {
486 t.Fatal(err)
487 }
488 aside, err := DestinationCheck(p, func() time.Time { return time.Unix(1700000000, 0) })
489 if err != nil {
490 t.Fatal(err)
491 }
492 want := filepath.Join(dd, "layout", "aside", host, host+".env.aside-1700000000")
493 if len(aside) != 1 || aside[0] != want {
494 t.Fatalf("aside = %v, want %s", aside, want)
495 }
496 if b, err := os.ReadFile(want); err != nil || string(b) != "CARLOS_ADMIN_TOKEN=previous-owner\n" {
497 t.Errorf("aside copy: %q %v", b, err)
498 }
499 entries, err := os.ReadDir(rawInstanceDir(dd, host))
500 if err != nil || len(entries) != 0 {
501 t.Errorf("instance directory still holds %v (%v); the aside must live outside it", entries, err)
502 }
503 // The aside directory is not a journal: JournalHosts lists hosts from
504 // *.json only, so the layout directory's new child does not read as a
505 // fenced host.
506 if hosts, err := JournalHosts(dd); err != nil || len(hosts) != 0 {
507 t.Errorf("JournalHosts = %v %v with only an aside directory present", hosts, err)
508 }
509}
510
511// LinkCheck refuses a file with a second name, a hard link inside the
512// shadow directory included, and a symlink inside it; a file with one
513// name passes.
514func TestLinkCheckRefusesASecondNameAndWalksTheShadow(t *testing.T) {
515 dd := t.TempDir()
516 host := "h.example.com"
517 p, _ := PlanFor(dd, filepath.Join(dd, "run"), host, ModeNested)
518 db := artifactNamed(t, p, "db")
519 shadow := artifactNamed(t, p, "litestream-shadow")
520 if err := os.WriteFile(db.Src, []byte("db"), 0o600); err != nil {
521 t.Fatal(err)
522 }
523 if err := os.MkdirAll(filepath.Join(shadow.Src, "generations"), 0o700); err != nil {
524 t.Fatal(err)
525 }
526 if err := os.WriteFile(filepath.Join(shadow.Src, "generations", "g"), []byte("g"), 0o600); err != nil {
527 t.Fatal(err)
528 }
529 for i := range p.Artifacts {
530 p.Artifacts[i].Present = p.Artifacts[i].Name == "db" || p.Artifacts[i].Name == "litestream-shadow"
531 }
532 src := func(a Artifact) string { return a.Src }
533 if err := LinkCheck(p, src); err != nil {
534 t.Fatalf("clean plan refused: %v", err)
535 }
536 other := filepath.Join(dd, "sibling.db")
537 if err := os.WriteFile(other, []byte("sibling"), 0o600); err != nil {
538 t.Fatal(err)
539 }
540 planted := filepath.Join(shadow.Src, "generations", "sibling")
541 if err := os.Link(other, planted); err != nil {
542 t.Skipf("hard links unavailable here: %v", err)
543 }
544 if err := LinkCheck(p, src); err == nil || !strings.Contains(err.Error(), "2 names") {
545 t.Fatalf("hard link in the shadow not refused: %v", err)
546 }
547 os.Remove(planted)
548 if err := os.Symlink(other, planted); err != nil {
549 t.Fatal(err)
550 }
551 if err := LinkCheck(p, src); err == nil || !strings.Contains(err.Error(), "not a regular file") {
552 t.Fatalf("symlink in the shadow not refused: %v", err)
553 }
554 os.Remove(planted)
555 if err := os.Link(db.Src, filepath.Join(dd, "second-name.db")); err != nil {
556 t.Fatal(err)
557 }
558 if err := LinkCheck(p, src); err == nil || !strings.Contains(err.Error(), "2 names") {
559 t.Fatalf("database with two names not refused: %v", err)
560 }
561}
562