1package snapshot
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "syscall"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/pkg/safedir"
13)
14
15func (r *Repository) ensureSession(id domain.IncarnationID) error {
16 sessionPath := r.sessionPath(id)
17 for _, directory := range []struct {
18 path string
19 phase string
20 }{
21 {r.dir, "repository"},
22 {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
23 {sessionPath, "session"},
24 {filepath.Join(sessionPath, repositoryObjectsDir), "objects"},
25 {filepath.Join(sessionPath, repositoryGenerations), "generations"},
26 } {
27 if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
28 return fmt.Errorf("create snapshot repository directory: %w", err)
29 }
30 }
31 return nil
32}
33
34func (r *Repository) ensurePrivateDirectory(dir string) error {
35 return r.ensurePrivateDirectoryPhase(dir, "snapshot directory")
36}
37
38func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) (err error) {
39 // The configured root is the trust boundary. Once it is private, every
40 // descendant is created through a pinned parent descriptor rather than via
41 // a path that an attacker can replace between checks.
42 if filepath.Clean(dir) == filepath.Clean(r.dir) {
43 _, statErr := os.Lstat(dir)
44 created := errors.Is(statErr, os.ErrNotExist)
45 if statErr != nil && !created {
46 return statErr
47 }
48 if err := safedir.EnsurePrivate(dir); err != nil {
49 return err
50 }
51 if created {
52 if err := syncDirectory(filepath.Dir(dir)); err != nil {
53 return fmt.Errorf("%s parent directory sync: %w", phase, err)
54 }
55 }
56 return nil
57 }
58 if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil {
59 return err
60 }
61 rel, ok := r.repositoryRelative(dir)
62 if !ok {
63 return fmt.Errorf("snapshot path outside repository")
64 }
65 root, err := r.openRoot()
66 if err != nil {
67 return err
68 }
69 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
70 created := false
71 mkdirErr := root.Mkdir(rel, 0o700)
72 if mkdirErr == nil {
73 created = true
74 }
75 if mkdirErr != nil && !errors.Is(mkdirErr, os.ErrExist) {
76 return mkdirErr
77 }
78 fi, err := root.Lstat(rel)
79 if err != nil {
80 return err
81 }
82 if !fi.IsDir() {
83 return fmt.Errorf("snapshot directory is not a directory")
84 }
85 if !privateDirectory(fi) {
86 return fmt.Errorf("snapshot directory is not private")
87 }
88 if created {
89 if err := r.syncDirectory(filepath.Dir(dir)); err != nil {
90 return fmt.Errorf("%s parent directory sync: %w", phase, err)
91 }
92 }
93 return nil
94}
95
96// writeImmutable publishes data with link(2), whose EEXIST behavior prevents a
97// raced target from being overwritten. A competing target is accepted only
98// after verifier reads it through the same secure descriptor path as Load.
99func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error {
100 dir := filepath.Dir(path)
101 phase := "object shard"
102 if filepath.Base(dir) == repositoryGenerations {
103 phase = "generation"
104 }
105 if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil {
106 return err
107 }
108 return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
109 if err := r.installImmutable(tempPath, path); err != nil {
110 if !errors.Is(err, os.ErrExist) {
111 return true, err
112 }
113 existing, readErr := r.readBounded(path)
114 if readErr != nil {
115 return true, readErr
116 }
117 return true, verifier(existing)
118 }
119 // link retains the temporary name, so cleanup must remove and sync it.
120 return true, nil
121 })
122}
123
124// writeMutable is used only for HEAD, the sole authoritative pointer allowed
125// to advance. The old or fully synced new HEAD is therefore always recoverable.
126func (r *Repository) writeMutable(path string, data []byte) error {
127 dir := filepath.Dir(path)
128 if err := r.ensurePrivateDirectory(dir); err != nil {
129 return err
130 }
131 return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) {
132 if err := r.rename(tempPath, path); err != nil {
133 return true, err
134 }
135 // rename consumed tempPath. Its directory sync persists both rename and
136 // the absence of the temporary entry.
137 if err := r.syncDirectory(dir); err != nil {
138 return true, err
139 }
140 return false, nil
141 })
142}
143
144// withAtomicTemp prepares a private, durable temporary file, invokes publish,
145// then closes and removes the temporary entry when publish reports that its
146// operation did not consume it. Cleanup errors are joined after the operation
147// error in the same order as the former immutable and mutable write paths.
148func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string) (removeTemp bool, err error)) error {
149 temp, err := r.createTemp(dir)
150 if err != nil {
151 return err
152 }
153 tempPath := temp.Name()
154 closed := false
155 cleanup := func(cause error) error {
156 if !closed {
157 if err := r.closeFile(temp); err != nil {
158 cause = errors.Join(cause, fmt.Errorf("close snapshot temporary file: %w", err))
159 }
160 closed = true
161 }
162 if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {
163 cause = errors.Join(cause, err)
164 } else if err == nil {
165 cause = errors.Join(cause, r.syncDirectory(dir))
166 }
167 return cause
168 }
169 if err := temp.Chmod(0o600); err != nil {
170 return cleanup(err)
171 }
172 if err := r.writeFile(temp, data); err != nil {
173 return cleanup(err)
174 }
175 if err := r.syncFile(temp); err != nil {
176 return cleanup(err)
177 }
178 if err := r.closeFile(temp); err != nil {
179 closed = true
180 return cleanup(fmt.Errorf("close snapshot temporary file: %w", err))
181 }
182 closed = true
183 removeTemp, err := publish(tempPath)
184 if !removeTemp {
185 return err
186 }
187 return cleanup(err)
188}
189
190func (r *Repository) createTemp(dir string) (*os.File, error) {
191 return r.createTempAt(dir)
192}
193func (r *Repository) writeFile(f *os.File, data []byte) error {
194 _, err := f.Write(data)
195 return err
196}
197func (r *Repository) syncFile(f *os.File) error {
198 return f.Sync()
199}
200func (r *Repository) closeFile(f *os.File) error {
201 return f.Close()
202}
203
204// joinCloseError retains a primary operation failure and appends contextual
205// close failures, including cleanup-only paths, without suppressing either.
206func joinCloseError(primary *error, operation string, closeErr error) {
207 if closeErr != nil {
208 *primary = errors.Join(*primary, fmt.Errorf("%s: %w", operation, closeErr))
209 }
210}
211
212func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
213 oldRel, ok := r.repositoryRelative(oldPath)
214 if !ok {
215 return fmt.Errorf("snapshot path outside repository")
216 }
217 newRel, ok := r.repositoryRelative(newPath)
218 if !ok {
219 return fmt.Errorf("snapshot path outside repository")
220 }
221 root, err := r.openRoot()
222 if err != nil {
223 return err
224 }
225 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
226 return root.Link(oldRel, newRel)
227}
228func (r *Repository) rename(oldPath, newPath string) (err error) {
229 oldRel, ok := r.repositoryRelative(oldPath)
230 if !ok {
231 return fmt.Errorf("snapshot path outside repository")
232 }
233 newRel, ok := r.repositoryRelative(newPath)
234 if !ok {
235 return fmt.Errorf("snapshot path outside repository")
236 }
237 root, err := r.openRoot()
238 if err != nil {
239 return err
240 }
241 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
242 return root.Rename(oldRel, newRel)
243}
244func (r *Repository) remove(path string) (err error) {
245 rel, ok := r.repositoryRelative(path)
246 if !ok {
247 return fmt.Errorf("snapshot path outside repository")
248 }
249 root, err := r.openRoot()
250 if err != nil {
251 return err
252 }
253 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
254 return root.Remove(rel)
255}
256func (r *Repository) syncDirectory(dir string) error {
257 return syncDirectory(dir)
258}
259func syncDirectory(dir string) error {
260 f, err := os.Open(dir)
261 if err != nil {
262 return err
263 }
264 if err := f.Sync(); err != nil {
265 _ = f.Close()
266 return err
267 }
268 return f.Close()
269}
270
271func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) {
272 data, err := r.readBounded(path)
273 if errors.Is(err, os.ErrNotExist) {
274 return nil, false, nil
275 }
276 return data, err == nil, err
277}
278
279// readBounded rejects a final symlink explicitly (rejectFinalSymlink) before
280// opening the component under the confined root, validates the opened
281// descriptor, then reads a hard bounded amount. It deliberately never trusts
282// path metadata obtained before opening the descriptor.
283func (r *Repository) readBounded(path string) (data []byte, err error) {
284 root, err := r.openRoot()
285 if err != nil {
286 return nil, err
287 }
288 defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
289 return r.readBoundedRoot(root, path)
290}
291
292func (r *Repository) readBoundedRoot(root *os.Root, path string) ([]byte, error) {
293 rel, ok := r.repositoryRelative(path)
294 if !ok {
295 return nil, fmt.Errorf("snapshot path outside repository")
296 }
297 if err := rejectFinalSymlink(root, rel); err != nil {
298 return nil, err
299 }
300 f, err := root.OpenFile(rel, syscall.O_RDONLY|syscall.O_NONBLOCK, 0)
301 if err != nil {
302 return nil, err
303 }
304 return readBoundedFile(f)
305}
306
307// readBounded remains for focused descriptor-hardening tests. Repository code
308// must use r.readBounded so every path stays confined to the pinned private root.
309func readBounded(path string) ([]byte, error) {
310 fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
311 if err != nil {
312 return nil, err
313 }
314 f := os.NewFile(uintptr(fd), path)
315 if f == nil {
316 _ = syscall.Close(fd)
317 return nil, fmt.Errorf("open snapshot file")
318 }
319 return readBoundedFile(f)
320}
321
322func readBoundedFile(f *os.File) ([]byte, error) {
323 fd := int(f.Fd())
324 var stat syscall.Stat_t
325 if err := syscall.Fstat(fd, &stat); err != nil {
326 _ = f.Close()
327 return nil, err
328 }
329 if stat.Mode&syscall.S_IFMT != syscall.S_IFREG {
330 _ = f.Close()
331 return nil, fmt.Errorf("not a regular file")
332 }
333 if int(stat.Uid) != os.Geteuid() {
334 _ = f.Close()
335 return nil, fmt.Errorf("snapshot file is not owned by effective uid")
336 }
337 if stat.Mode&0o077 != 0 {
338 _ = f.Close()
339 return nil, fmt.Errorf("snapshot file has unsafe permissions")
340 }
341 if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) {
342 _ = f.Close()
343 return nil, fmt.Errorf("snapshot file too large")
344 }
345 data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1))
346 closeErr := f.Close()
347 if readErr != nil {
348 return nil, readErr
349 }
350 if closeErr != nil {
351 return nil, closeErr
352 }
353 if len(data) > maxRepositoryRead {
354 return nil, fmt.Errorf("snapshot file too large")
355 }
356 return data, nil
357}