v0.3.0 99dc266

atomic.go RawHistoryFolder

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}

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