atomic.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close