1//go:build !js
2// +build !js
3
4/*
5 Copyright 2022 The Flux authors.
6
7 Licensed under the Apache License, Version 2.0 (the "License");
8 you may not use this file except in compliance with the License.
9 You may obtain a copy of the License at
10
11 http://www.apache.org/licenses/LICENSE-2.0
12
13 Unless required by applicable law or agreed to in writing, software
14 distributed under the License is distributed on an "AS IS" BASIS,
15 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 See the License for the specific language governing permissions and
17 limitations under the License.
18*/
19
20package osfs
21
22import (
23 "errors"
24 "fmt"
25 "os"
26 "path/filepath"
27 "strings"
28
29 securejoin "github.com/cyphar/filepath-securejoin"
30 "github.com/go-git/go-billy/v5"
31)
32
33var (
34 // ErrBaseDirCannotBeRemoved is returned when removing the BoundOS base dir.
35 ErrBaseDirCannotBeRemoved = errors.New("base dir cannot be removed")
36
37 // ErrBaseDirCannotBeRenamed is returned when renaming the BoundOS base dir.
38 ErrBaseDirCannotBeRenamed = errors.New("base dir cannot be renamed")
39
40 dotPrefixes = dotPathPrefixes()
41 dotSeparators = dotPathSeparators()
42)
43
44func dotPathPrefixes() []string {
45 if filepath.Separator == '\\' {
46 return []string{"./", ".\\"}
47 }
48 return []string{"./"}
49}
50
51func dotPathSeparators() string {
52 if filepath.Separator == '\\' {
53 return `/\`
54 }
55 return `/`
56}
57
58// BoundOS is a fs implementation based on the OS filesystem which is bound to
59// a base dir.
60// Prefer this fs implementation over ChrootOS.
61//
62// Behaviours of note:
63// 1. Read and write operations can only be directed to files which descends
64// from the base dir.
65// 2. Symlinks don't have their targets modified, and therefore can point
66// to locations outside the base dir or to non-existent paths.
67// 3. Readlink and Lstat ensures that the link file is located within the base
68// dir, evaluating any symlinks that file or base dir may contain.
69type BoundOS struct {
70 baseDir string
71 deduplicatePath bool
72}
73
74func newBoundOS(d string, deduplicatePath bool) billy.Filesystem {
75 return &BoundOS{baseDir: d, deduplicatePath: deduplicatePath}
76}
77
78func (fs *BoundOS) Create(filename string) (billy.File, error) {
79 return fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, defaultCreateMode)
80}
81
82func (fs *BoundOS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
83 filename = fs.expandDot(filename)
84 fn, err := fs.abs(filename)
85 if err != nil {
86 return nil, err
87 }
88 return openFile(fn, flag, perm, fs.createDir)
89}
90
91func (fs *BoundOS) ReadDir(path string) ([]os.FileInfo, error) {
92 path = fs.expandDot(path)
93 dir, err := fs.abs(path)
94 if err != nil {
95 return nil, err
96 }
97
98 return readDir(dir)
99}
100
101func (fs *BoundOS) Rename(from, to string) error {
102 if fs.isBaseDir(from) {
103 return ErrBaseDirCannotBeRenamed
104 }
105 from = fs.expandDot(from)
106 to = fs.expandDot(to)
107
108 f, err := fs.abs(from)
109 if err != nil {
110 return err
111 }
112 t, err := fs.abs(to)
113 if err != nil {
114 return err
115 }
116
117 // MkdirAll for target name.
118 if err := fs.createDir(t); err != nil {
119 return err
120 }
121
122 return os.Rename(f, t)
123}
124
125func (fs *BoundOS) MkdirAll(path string, perm os.FileMode) error {
126 path = fs.expandDot(path)
127 dir, err := fs.abs(path)
128 if err != nil {
129 return err
130 }
131 return os.MkdirAll(dir, perm)
132}
133
134func (fs *BoundOS) Open(filename string) (billy.File, error) {
135 return fs.OpenFile(filename, os.O_RDONLY, 0)
136}
137
138func (fs *BoundOS) Stat(filename string) (os.FileInfo, error) {
139 filename = fs.expandDot(filename)
140 filename, err := fs.abs(filename)
141 if err != nil {
142 return nil, err
143 }
144 return os.Stat(filename)
145}
146
147func (fs *BoundOS) Remove(filename string) error {
148 if fs.isBaseDir(filename) {
149 return ErrBaseDirCannotBeRemoved
150 }
151 filename = fs.expandDot(filename)
152
153 fn, err := fs.abs(filename)
154 if err != nil {
155 return err
156 }
157 return os.Remove(fn)
158}
159
160// TempFile creates a temporary file. If dir is empty, the file
161// will be created within the OS Temporary dir. If dir is provided
162// it must descend from the current base dir.
163func (fs *BoundOS) TempFile(dir, prefix string) (billy.File, error) {
164 if dir != "" {
165 var err error
166 dir = fs.expandDot(dir)
167 dir, err = fs.abs(dir)
168 if err != nil {
169 return nil, err
170 }
171
172 _, err = os.Stat(dir)
173 if err != nil && os.IsNotExist(err) {
174 err = os.MkdirAll(dir, defaultDirectoryMode)
175 if err != nil {
176 return nil, err
177 }
178 }
179 }
180
181 return tempFile(dir, prefix)
182}
183
184func (fs *BoundOS) Join(elem ...string) string {
185 return filepath.Join(elem...)
186}
187
188func (fs *BoundOS) RemoveAll(path string) error {
189 if fs.isBaseDir(path) {
190 return ErrBaseDirCannotBeRemoved
191 }
192 path = fs.expandDot(path)
193
194 dir, err := fs.abs(path)
195 if err != nil {
196 return err
197 }
198 return os.RemoveAll(dir)
199}
200
201func (fs *BoundOS) Symlink(target, link string) error {
202 link = fs.expandDot(link)
203 ln, err := fs.abs(link)
204 if err != nil {
205 return err
206 }
207 // MkdirAll for containing dir.
208 if err := fs.createDir(ln); err != nil {
209 return err
210 }
211 return os.Symlink(target, ln)
212}
213
214func (fs *BoundOS) Lstat(filename string) (os.FileInfo, error) {
215 filename = fs.expandDot(filename)
216 filename = filepath.Clean(filename)
217 if !filepath.IsAbs(filename) {
218 filename = filepath.Join(fs.baseDir, filename)
219 }
220 if ok, err := fs.insideBaseDirEval(filename); !ok {
221 return nil, err
222 }
223 return os.Lstat(filename)
224}
225
226func (fs *BoundOS) Readlink(link string) (string, error) {
227 link = fs.expandDot(link)
228 if !filepath.IsAbs(link) {
229 link = filepath.Clean(filepath.Join(fs.baseDir, link))
230 }
231 if ok, err := fs.insideBaseDirEval(link); !ok {
232 return "", err
233 }
234 return os.Readlink(link)
235}
236
237func (fs *BoundOS) Chmod(path string, mode os.FileMode) error {
238 path = fs.expandDot(path)
239 abspath, err := fs.abs(path)
240 if err != nil {
241 return err
242 }
243 return os.Chmod(abspath, mode)
244}
245
246// Chroot returns a new OS filesystem, with the base dir set to the
247// result of joining the provided path with the underlying base dir.
248func (fs *BoundOS) Chroot(path string) (billy.Filesystem, error) {
249 joined, err := securejoin.SecureJoin(fs.baseDir, path)
250 if err != nil {
251 return nil, err
252 }
253 return New(joined, WithBoundOS()), nil
254}
255
256// Root returns the current base dir of the billy.Filesystem.
257// This is required in order for this implementation to be a drop-in
258// replacement for other upstream implementations (e.g. memory and osfs).
259func (fs *BoundOS) Root() string {
260 return fs.baseDir
261}
262
263func (fs *BoundOS) createDir(fullpath string) error {
264 dir := filepath.Dir(fullpath)
265 if dir != "." {
266 if err := os.MkdirAll(dir, defaultDirectoryMode); err != nil {
267 return err
268 }
269 }
270
271 return nil
272}
273
274func (fs *BoundOS) expandDot(path string) string {
275 if path == "." {
276 return fs.baseDir
277 }
278 for _, prefix := range dotPrefixes {
279 if strings.HasPrefix(path, prefix) {
280 path = strings.TrimLeft(strings.TrimPrefix(path, prefix), dotSeparators)
281 if path == "" {
282 return fs.baseDir
283 }
284 return path
285 }
286 }
287 return path
288}
289
290func (fs *BoundOS) isBaseDir(path string) bool {
291 if path == "" || filepath.Clean(path) == "." {
292 return true
293 }
294 path = fs.expandDot(path)
295 if filepath.Clean(path) == filepath.Clean(fs.baseDir) {
296 return true
297 }
298 abspath, err := fs.abs(path)
299 if err != nil {
300 return false
301 }
302 return filepath.Clean(abspath) == filepath.Clean(fs.baseDir)
303}
304
305// abs transforms filename to an absolute path, taking into account the base dir.
306// Relative paths won't be allowed to ascend the base dir, so `../file` will become
307// `/working-dir/file`.
308//
309// Note that if filename is a symlink, the returned address will be the target of the
310// symlink.
311func (fs *BoundOS) abs(filename string) (string, error) {
312 if filename == fs.baseDir {
313 filename = string(filepath.Separator)
314 }
315
316 path, err := securejoin.SecureJoin(fs.baseDir, filename)
317 if err != nil {
318 return "", err
319 }
320
321 if fs.deduplicatePath {
322 vol := filepath.VolumeName(fs.baseDir)
323 dup := filepath.Join(fs.baseDir, fs.baseDir[len(vol):])
324 if strings.HasPrefix(path, dup+string(filepath.Separator)) {
325 return fs.abs(path[len(dup):])
326 }
327 }
328 return path, nil
329}
330
331// insideBaseDirEval checks whether filename is contained within
332// a dir that is within the fs.baseDir, by first evaluating any symlinks
333// that either filename or fs.baseDir may contain.
334func (fs *BoundOS) insideBaseDirEval(filename string) (bool, error) {
335 // "/" contains all others.
336 if fs.baseDir == "/" || fs.baseDir == filename {
337 return true, nil
338 }
339 dir, err := filepath.EvalSymlinks(filepath.Dir(filename))
340 if dir == "" || os.IsNotExist(err) {
341 dir = filepath.Dir(filename)
342 }
343 wd, err := filepath.EvalSymlinks(fs.baseDir)
344 if wd == "" || os.IsNotExist(err) {
345 wd = fs.baseDir
346 }
347 if filename != wd && dir != wd && !strings.HasPrefix(dir, wd+string(filepath.Separator)) {
348 return false, fmt.Errorf("%q: path outside base dir %q: %w", filename, fs.baseDir, os.ErrNotExist)
349 }
350 return true, nil
351}