1package snapshot
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "syscall"
12)
13
14func (r *Repository) openRoot() (*os.Root, error) {
15 // os.OpenRoot follows a final symlink, so validate that its pinned directory
16 // is still the non-symlink directory at the configured path.
17 root, err := os.OpenRoot(r.dir)
18 if err != nil {
19 return nil, err
20 }
21 current, err := os.Lstat(r.dir)
22 if err != nil {
23 return nil, r.closeRootOnError(root, err)
24 }
25 if !current.IsDir() || current.Mode()&os.ModeSymlink != 0 {
26 return nil, r.closeRootOnError(root, &os.PathError{Op: "open", Path: r.dir, Err: syscall.ELOOP})
27 }
28 pinned, err := root.Stat(".")
29 if err != nil {
30 return nil, r.closeRootOnError(root, err)
31 }
32 if !os.SameFile(current, pinned) {
33 return nil, r.closeRootOnError(root, &os.PathError{Op: "open", Path: r.dir, Err: syscall.ESTALE})
34 }
35 if !privateDirectory(pinned) {
36 return nil, r.closeRootOnError(root, fmt.Errorf("snapshot directory is not private"))
37 }
38 return root, nil
39}
40
41func privateDirectory(fi os.FileInfo) bool {
42 st, ok := fi.Sys().(*syscall.Stat_t)
43 return ok && fi.IsDir() && int(st.Uid) == os.Geteuid() && fi.Mode().Perm() == 0o700
44}
45
46func (r *Repository) closeRoot(root *os.Root) error {
47 return root.Close()
48}
49
50func (r *Repository) closeRootOnError(root *os.Root, err error) error {
51 return errors.Join(err, r.closeRoot(root))
52}
53
54// rejectFinalSymlink preserves the pre-os.Root guarantee that the final
55// component is never a symlink. Root confinement is the hard boundary; this
56// is defense-in-depth with a benign TOCTOU window inside a 0700 directory.
57func rejectFinalSymlink(root *os.Root, rel string) error {
58 fi, err := root.Lstat(rel)
59 if err != nil {
60 return err
61 }
62 if fi.Mode()&os.ModeSymlink != 0 {
63 return &os.PathError{Op: "open", Path: rel, Err: syscall.ELOOP}
64 }
65 return nil
66}
67
68func (r *Repository) openDirectory(path string) (file *os.File, err error) {
69 rel, ok := r.repositoryRelative(path)
70 if !ok {
71 return nil, fmt.Errorf("snapshot path outside repository")
72 }
73 root, err := r.openRoot()
74 if err != nil {
75 return nil, err
76 }
77 defer func() {
78 closeErr := r.closeRoot(root)
79 if closeErr != nil && file != nil {
80 joinCloseError(&err, "close snapshot directory after root close failure", file.Close())
81 file = nil
82 }
83 joinCloseError(&err, "close snapshot root", closeErr)
84 }()
85 if err := rejectFinalSymlink(root, rel); err != nil {
86 return nil, err
87 }
88 return root.OpenFile(rel, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
89}
90
91func (r *Repository) repositoryRelative(path string) (string, bool) {
92 if path == r.dir {
93 return ".", true
94 }
95 prefix := r.dir + string(filepath.Separator)
96 if !strings.HasPrefix(path, prefix) {
97 return "", false
98 }
99 rel := strings.TrimPrefix(path, prefix)
100 if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
101 return "", false
102 }
103 return rel, true
104}
105
106// createTempAt creates an exclusively owned temporary file in an already
107// validated directory. os.CreateTemp(path) would resolve that path again.
108func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
109 rel, ok := r.repositoryRelative(dir)
110 if !ok {
111 return nil, fmt.Errorf("snapshot path outside repository")
112 }
113 root, err := r.openRoot()
114 if err != nil {
115 return nil, err
116 }
117 defer func() {
118 joinCloseError(&err, "close snapshot root", r.closeRoot(root))
119 if err != nil && temp != nil {
120 joinCloseError(&err, "close snapshot temporary file after root close failure", temp.Close())
121 temp = nil
122 }
123 }()
124 for range 100 {
125 var random [8]byte
126 if _, err := rand.Read(random[:]); err != nil {
127 return nil, err
128 }
129 name := ".tmp-" + hex.EncodeToString(random[:])
130 temp, err = root.OpenFile(filepath.Join(rel, name), syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL, 0o600)
131 if errors.Is(err, os.ErrExist) {
132 continue
133 }
134 if err != nil {
135 return nil, err
136 }
137 return temp, nil
138 }
139 return nil, fmt.Errorf("create snapshot temporary file: too many collisions")
140}