1// Package secretfiles stores file-delivered app secrets on the host.
2//
3// Layout: <data_dir>/secrets-run/<app>/<container-name>/<KEY>, directories
4// 0700 and files 0444. All access goes through an os.Root so symlinks
5// cannot escape the base directory. Values and paths are never logged.
6package secretfiles
7
8import (
9 "context"
10 "errors"
11 "fmt"
12 "io/fs"
13 "maps"
14 "os"
15 "path/filepath"
16 "slices"
17 "strings"
18 "syscall"
19
20 "github.com/bnema/gordon/internal/boundaries/out"
21 "github.com/bnema/gordon/internal/domain"
22)
23
24const (
25 dirMode os.FileMode = 0o700
26 fileMode os.FileMode = 0o444
27 // maxContainerName bounds a container directory name.
28 maxContainerName = 255
29)
30
31// Store implements out.SecretFileStore on the local filesystem.
32type Store struct {
33 dir string
34 root *os.Root
35}
36
37var _ out.SecretFileStore = (*Store)(nil)
38
39// NewStore opens (creating if needed) <dataDir>/secrets-run.
40func NewStore(dataDir string) (*Store, error) {
41 // Bind sources must be absolute: resolve a relative data dir once.
42 dir, err := filepath.Abs(filepath.Join(dataDir, "secrets-run"))
43 if err != nil {
44 return nil, fmt.Errorf("secret files: resolve base directory: %w", err)
45 }
46 if err := os.MkdirAll(dir, dirMode); err != nil {
47 return nil, fmt.Errorf("secret files: create base directory: %w", err)
48 }
49 info, err := os.Lstat(dir)
50 if err != nil {
51 return nil, fmt.Errorf("secret files: stat base directory: %w", err)
52 }
53 if !info.IsDir() {
54 return nil, errors.New("secret files: base directory must be a directory, not a symlink")
55 }
56 if err := os.Chmod(dir, dirMode); err != nil {
57 return nil, fmt.Errorf("secret files: chmod base directory: %w", err)
58 }
59 root, err := os.OpenRoot(dir)
60 if err != nil {
61 return nil, fmt.Errorf("secret files: open base directory: %w", err)
62 }
63 return &Store{dir: dir, root: root}, nil
64}
65
66// Dir returns the absolute base directory.
67func (s *Store) Dir() string { return s.dir }
68
69// Close releases the base directory handle.
70func (s *Store) Close() error { return s.root.Close() }
71
72// Write creates the container's directory with one 0444 file per key. The
73// directory must not exist yet: container names are unique per create, so
74// an existing directory belongs to another container and is never touched.
75// Crash leftovers are removed by the sweeps, not here.
76func (s *Store) Write(_ context.Context, app, container string, values map[string]string) (map[string]string, error) {
77 if err := validateSet(app, container); err != nil {
78 return nil, err
79 }
80 for key := range values {
81 if err := domain.ValidateEnvKey(key); err != nil {
82 return nil, fmt.Errorf("secret files: %w", err)
83 }
84 }
85 if err := s.root.MkdirAll(app, dirMode); err != nil {
86 return nil, fmt.Errorf("secret files: create app directory: %w", err)
87 }
88 if err := s.root.Chmod(app, dirMode); err != nil {
89 return nil, fmt.Errorf("secret files: chmod app directory: %w", err)
90 }
91 rel := filepath.Join(app, container)
92 if err := s.root.Mkdir(rel, dirMode); err != nil {
93 return nil, fmt.Errorf("secret files: create container directory: %w", err)
94 }
95 paths, err := s.writeFiles(rel, values)
96 if err != nil {
97 _ = s.root.RemoveAll(rel)
98 return nil, err
99 }
100 return paths, nil
101}
102
103func (s *Store) writeFiles(rel string, values map[string]string) (map[string]string, error) {
104 if err := s.root.Chmod(rel, dirMode); err != nil {
105 return nil, fmt.Errorf("secret files: chmod container directory: %w", err)
106 }
107 paths := make(map[string]string, len(values))
108 for _, key := range slices.Sorted(maps.Keys(values)) {
109 name := filepath.Join(rel, key)
110 if err := s.writeFile(name, values[key]); err != nil {
111 return nil, fmt.Errorf("secret files: write %s: %w", key, err)
112 }
113 paths[key] = filepath.Join(s.dir, name)
114 }
115 return paths, nil
116}
117
118func (s *Store) writeFile(name, value string) error {
119 f, err := s.root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileMode)
120 if err != nil {
121 return err
122 }
123 if _, err := f.WriteString(value); err != nil {
124 _ = f.Close()
125 return err
126 }
127 if err := f.Close(); err != nil {
128 return err
129 }
130 return s.root.Chmod(name, fileMode)
131}
132
133// Read returns one file's content.
134func (s *Store) Read(_ context.Context, app, container, key string) (string, error) {
135 if err := validateSet(app, container); err != nil {
136 return "", err
137 }
138 if err := domain.ValidateEnvKey(key); err != nil {
139 return "", fmt.Errorf("secret files: %w", err)
140 }
141 data, err := s.root.ReadFile(filepath.Join(app, container, key))
142 if err != nil {
143 return "", fmt.Errorf("secret files: read %s: %w", key, err)
144 }
145 return string(data), nil
146}
147
148// Remove deletes the container's directory and the app directory when it
149// becomes empty. A missing directory is not an error.
150func (s *Store) Remove(_ context.Context, app, container string) error {
151 if err := validateSet(app, container); err != nil {
152 return err
153 }
154 if err := s.root.RemoveAll(filepath.Join(app, container)); err != nil {
155 return fmt.Errorf("secret files: remove container directory: %w", err)
156 }
157 if err := s.root.Remove(app); err != nil && !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) {
158 return fmt.Errorf("secret files: remove app directory: %w", err)
159 }
160 return nil
161}
162
163// List returns every container directory, sorted by app then container.
164func (s *Store) List(_ context.Context) ([]out.SecretFileSet, error) {
165 fsys := s.root.FS()
166 apps, err := fs.ReadDir(fsys, ".")
167 if err != nil {
168 return nil, fmt.Errorf("secret files: list apps: %w", err)
169 }
170 var sets []out.SecretFileSet
171 for _, app := range apps {
172 if !app.IsDir() {
173 continue
174 }
175 containers, err := fs.ReadDir(fsys, app.Name())
176 if err != nil {
177 return nil, fmt.Errorf("secret files: list containers: %w", err)
178 }
179 for _, c := range containers {
180 if c.IsDir() {
181 sets = append(sets, out.SecretFileSet{App: app.Name(), Container: c.Name()})
182 }
183 }
184 }
185 // fs.ReadDir returns entries sorted by name, so sets are already ordered.
186 return sets, nil
187}
188
189func validateSet(app, container string) error {
190 if err := domain.ValidateAppName(app); err != nil {
191 return fmt.Errorf("secret files: %w", err)
192 }
193 if container == "" || len(container) > maxContainerName || container == "." || container == ".." ||
194 filepath.Base(container) != container || strings.ContainsAny(container, "/\\\x00") {
195 return errors.New("secret files: invalid container name")
196 }
197 return nil
198}