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