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}