1//go:build unix
2
3package lifecycle
4
5import (
6 "bufio"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "syscall"
15 "testing"
16 "time"
17
18 "github.com/stretchr/testify/require"
19)
20
21func TestLifecycleLockProcessHelper(t *testing.T) {
22 if os.Getenv("VEV_LIFECYCLE_LOCK_HELPER") != "1" {
23 t.Skip("helper process")
24 }
25
26 owner, err := TryAcquire(os.Getenv("VEV_LIFECYCLE_LOCK_DIR"))
27 require.NoError(t, err)
28 defer func() { require.NoError(t, owner.Release()) }()
29
30 _, err = fmt.Fprintln(os.Stdout, "locked")
31 require.NoError(t, err)
32 _, err = io.Copy(io.Discard, os.Stdin)
33 require.NoError(t, err)
34}
35
36func TestLifecycleLock(t *testing.T) {
37 t.Run("process contention and reacquire", testLifecycleLockProcessContentionAndReacquire)
38 t.Run("acquire cancellation", testLifecycleLockAcquireCancellation)
39 t.Run("unavailable runtime directory", testLifecycleLockUnavailableRuntimeDirectory)
40 t.Run("lock file is owner-only regular", testLifecycleLockIsOwnerOnlyRegular)
41 t.Run("owner-only variant is accepted", testLifecycleLockOwnerOnlyVariantAccepted)
42 t.Run("restrictive umask normalizes to owner-only", testLifecycleLockRestrictiveUmaskNormalizes)
43 t.Run("symlinked lock path fails closed", testLifecycleLockSymlinkFailsClosed)
44 t.Run("nonregular lock path fails closed", testLifecycleLockNonRegularFailsClosed)
45 t.Run("loose permissions fail closed", testLifecycleLockLoosePermissionsFailClosed)
46}
47
48func testLifecycleLockIsOwnerOnlyRegular(t *testing.T) {
49 runtimeDir := filepath.Join(t.TempDir(), "runtime")
50 owner, err := TryAcquire(runtimeDir)
51 require.NoError(t, err)
52 defer func() { require.NoError(t, owner.Release()) }()
53
54 info, err := os.Lstat(Path(runtimeDir))
55 require.NoError(t, err)
56 require.True(t, info.Mode().IsRegular(), "the lock must be a regular file")
57 require.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "the lock must be owner-only")
58}
59
60// testLifecycleLockOwnerOnlyVariantAccepted proves a pre-existing owner-only
61// lock with a different owner bit pattern is trusted, so an installation whose
62// lock was created under a variant umask can still start the daemon.
63func testLifecycleLockOwnerOnlyVariantAccepted(t *testing.T) {
64 runtimeDir := filepath.Join(t.TempDir(), "runtime")
65 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
66 require.NoError(t, os.WriteFile(Path(runtimeDir), nil, 0o600))
67 require.NoError(t, os.Chmod(Path(runtimeDir), 0o700))
68
69 owner, err := TryAcquire(runtimeDir)
70 require.NoError(t, err)
71 require.NoError(t, owner.Release())
72}
73
74// testLifecycleLockRestrictiveUmaskNormalizes proves a newly created lock is
75// chmodded to 0600 even when the process umask would have stripped owner bits,
76// so the lock is always exactly owner-only after creation.
77func testLifecycleLockRestrictiveUmaskNormalizes(t *testing.T) {
78 runtimeDir := filepath.Join(t.TempDir(), "runtime")
79 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
80 previous := syscall.Umask(0o400)
81 defer syscall.Umask(previous)
82
83 owner, err := TryAcquire(runtimeDir)
84 require.NoError(t, err)
85 defer func() { require.NoError(t, owner.Release()) }()
86
87 info, err := os.Lstat(Path(runtimeDir))
88 require.NoError(t, err)
89 require.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "a newly created lock must be normalized to owner-only")
90}
91
92func testLifecycleLockSymlinkFailsClosed(t *testing.T) {
93 runtimeDir := filepath.Join(t.TempDir(), "runtime")
94 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
95 target := filepath.Join(t.TempDir(), "target.lock")
96 require.NoError(t, os.WriteFile(target, nil, 0o600))
97 require.NoError(t, os.Symlink(target, Path(runtimeDir)))
98
99 _, err := TryAcquire(runtimeDir)
100 require.Error(t, err)
101 require.False(t, errors.Is(err, ErrBusy), "a symlinked lock path is an error, not contention")
102 require.Contains(t, err.Error(), "symlink")
103}
104
105func testLifecycleLockNonRegularFailsClosed(t *testing.T) {
106 runtimeDir := filepath.Join(t.TempDir(), "runtime")
107 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
108 require.NoError(t, syscall.Mkfifo(Path(runtimeDir), 0o600))
109
110 _, err := TryAcquire(runtimeDir)
111 require.Error(t, err)
112 require.False(t, errors.Is(err, ErrBusy), "a non-regular lock path is an error, not contention")
113 require.Contains(t, err.Error(), "not a regular file")
114}
115
116func testLifecycleLockLoosePermissionsFailClosed(t *testing.T) {
117 runtimeDir := filepath.Join(t.TempDir(), "runtime")
118 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
119 require.NoError(t, os.WriteFile(Path(runtimeDir), nil, 0o600))
120 require.NoError(t, os.Chmod(Path(runtimeDir), 0o644))
121
122 _, err := TryAcquire(runtimeDir)
123 require.Error(t, err)
124 require.False(t, errors.Is(err, ErrBusy), "a loose lock file is an error, not contention")
125 require.Contains(t, err.Error(), "permissions")
126}
127
128func testLifecycleLockProcessContentionAndReacquire(t *testing.T) {
129 runtimeDir := filepath.Join(t.TempDir(), "runtime")
130 cmd := exec.Command(os.Args[0], "-test.run=^TestLifecycleLockProcessHelper$")
131 cmd.Env = append(os.Environ(),
132 "VEV_LIFECYCLE_LOCK_HELPER=1",
133 "VEV_LIFECYCLE_LOCK_DIR="+runtimeDir,
134 )
135 stdin, err := cmd.StdinPipe()
136 require.NoError(t, err)
137 stdout, err := cmd.StdoutPipe()
138 require.NoError(t, err)
139 cmd.Stderr = os.Stderr
140 require.NoError(t, cmd.Start())
141 defer func() {
142 _ = stdin.Close()
143 _ = cmd.Wait()
144 }()
145
146 line, err := bufio.NewReader(stdout).ReadString('\n')
147 require.NoError(t, err)
148 require.Equal(t, "locked\n", line)
149
150 _, err = TryAcquire(runtimeDir)
151 require.ErrorIs(t, err, ErrBusy)
152
153 require.NoError(t, stdin.Close())
154 require.NoError(t, cmd.Wait())
155
156 owner, err := TryAcquire(runtimeDir)
157 require.NoError(t, err)
158 require.NoError(t, owner.Release())
159 require.NoError(t, owner.Release(), "release is idempotent")
160}
161
162func testLifecycleLockAcquireCancellation(t *testing.T) {
163 runtimeDir := filepath.Join(t.TempDir(), "runtime")
164 owner, err := TryAcquire(runtimeDir)
165 require.NoError(t, err)
166 defer func() { require.NoError(t, owner.Release()) }()
167
168 ctx, cancel := context.WithCancel(context.Background())
169 cancel()
170
171 _, err = Acquire(ctx, runtimeDir, time.Millisecond)
172 require.ErrorIs(t, err, context.Canceled)
173}
174
175func testLifecycleLockUnavailableRuntimeDirectory(t *testing.T) {
176 runtimePath := t.TempDir() + "/not-a-directory"
177 require.NoError(t, os.WriteFile(runtimePath, []byte("occupied"), 0o600))
178
179 _, err := TryAcquire(runtimePath)
180 require.Error(t, err)
181 require.False(t, errors.Is(err, ErrBusy))
182}