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 return openRootDirectory(root, rel)
86}
87
88// openRootDirectory opens rel through a pinned root, refusing a final symlink.
89func openRootDirectory(root *os.Root, rel string) (*os.File, error) {
90 if err := rejectFinalSymlink(root, rel); err != nil {
91 return nil, err
92 }
93 return root.OpenFile(rel, syscall.O_RDONLY|syscall.O_DIRECTORY, 0)
94}
95
96func (r *Repository) repositoryRelative(path string) (string, bool) {
97 if path == r.dir {
98 return ".", true
99 }
100 prefix := r.dir + string(filepath.Separator)
101 if !strings.HasPrefix(path, prefix) {
102 return "", false
103 }
104 rel := strings.TrimPrefix(path, prefix)
105 if rel == "" || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
106 return "", false
107 }
108 return rel, true
109}
110
111// createTempAt creates an exclusively owned temporary file in an already
112// validated directory. os.CreateTemp(path) would resolve that path again.
113func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
114 rel, ok := r.repositoryRelative(dir)
115 if !ok {
116 return nil, fmt.Errorf("snapshot path outside repository")
117 }
118 root, err := r.openRoot()
119 if err != nil {
120 return nil, err
121 }
122 defer func() {
123 joinCloseError(&err, "close snapshot root", r.closeRoot(root))
124 if err != nil && temp != nil {
125 joinCloseError(&err, "close snapshot temporary file after root close failure", temp.Close())
126 temp = nil
127 }
128 }()
129 for range 100 {
130 var random [8]byte
131 if _, err := rand.Read(random[:]); err != nil {
132 return nil, err
133 }
134 name := ".tmp-" + hex.EncodeToString(random[:])
135 temp, err = root.OpenFile(filepath.Join(rel, name), syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL, 0o600)
136 if errors.Is(err, os.ErrExist) {
137 continue
138 }
139 if err != nil {
140 return nil, err
141 }
142 return temp, nil
143 }
144 return nil, fmt.Errorf("create snapshot temporary file: too many collisions")
145}