blob: e06c6c479270a60393e1fa5f9ea6c387deca22ba [file] [log] [blame]
// Copyright 2015 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package fs
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/cipd/client/cipd/internal/retry"
)
// FileSystem abstracts operations that touch single file system subpath.
//
// All functions operate in terms of native file paths. It exists mostly to hide
// differences between file system semantic on Windows and Linux\Mac.
//
// IO errors are returned unannotated and unwrapped, so they can be examined by
// os.IsNotExist and similar functions. Higher layers of the CIPD client should
// eventually annotated them with an appropriate cipderr tag (usually
// cipderr.IO).
type FileSystem interface {
// Root returns absolute path to a directory FileSystem operates in.
//
// All FS actions are restricted to this directory.
Root() string
// CaseSensitive returns true if the file system that has the root is
// case-sensitive.
CaseSensitive() (bool, error)
// CwdRelToAbs converts a path relative to cwd to an absolute one.
//
// If also verifies the path is under the root path of the FileSystem object.
// If passed path is already absolute, just checks that it's under the root.
CwdRelToAbs(path string) (string, error)
// RootRelToAbs converts a path relative to Root() to an absolute one.
//
// It verifies the path is under the root path of the FileSystem object.
// If passed path is already absolute, just checks that it's under the root.
RootRelToAbs(path string) (string, error)
// OpenFile opens a file and returns its file handle.
//
// Files opened with OpenFile can be safely manipulated by other
// FileSystem functions.
//
// This differs from os.Open notably on Windows, where OpenFile ensures that
// files are open with FILE_SHARE_DELETE permission to enable them to be
// atomically renamed without contention.
OpenFile(path string) (*os.File, error)
// Stat returns a FileInfo describing the named file, following symlinks.
Stat(ctx context.Context, path string) (os.FileInfo, error)
// Lstat returns a FileInfo describing the named file, not following symlinks.
Lstat(ctx context.Context, path string) (os.FileInfo, error)
// EnsureDirectory creates a directory at given native path.
//
// Follows symlinks. Does nothing it the path already exists and it is a
// directory (or a symlink pointing to a directory). Replaces an existing file
// (or a symlink to a file, or a broken symlink) with a directory.
//
// It takes an absolute path or a path relative to the current working
// directory and always returns absolute path.
EnsureDirectory(ctx context.Context, path string) (string, error)
// EnsureSymlink creates a symlink pointing to a target.
//
// It will create full directory path to the symlink if necessary.
EnsureSymlink(ctx context.Context, path string, target string) error
// EnsureFile creates a file and calls the function to write file content.
//
// It will create full directory path to the file if necessary.
EnsureFile(ctx context.Context, path string, write func(*os.File) error) error
// EnsureFileGone removes a file or an empty directory, logging the errors.
//
// Missing file is not an error.
//
// Fails if 'path' is a non-empty directory. Use EnsureDirectoryGone for this
// case. Treats an empty directory as a file though (deletes it), since it is
// difficult to distinguish the two without an extra syscall.
EnsureFileGone(ctx context.Context, path string) error
// EnsureDirectoryGone recursively removes a directory.
EnsureDirectoryGone(ctx context.Context, path string) error
// Renames oldpath to newpath as "atomically" as possible.
//
// If newpath already exists (be it a file or a directory), removes it first.
// If oldpath is a symlink, it's moved as is (e.g. as a symlink).
//
// Not really atomic if newpath exists and it is a directory. There's a small
// interval of time when 'oldpath' still exists, former 'newpath' is deleted,
// but 'newpath' is not yet created. If someone creates 'newpath' during this
// time, we retry the whole operation from scratch, and keep retrying like
// that for 10 sec.
Replace(ctx context.Context, oldpath, newpath string) error
// CleanupTrash attempts to remove all files that ended up in the trash.
//
// This is a best effort operation. Errors are logged (either at Debug or
// Warning level, depending on severity of the trash state).
CleanupTrash(ctx context.Context)
}
// NewFileSystem returns default FileSystem implementation.
//
// It operates with files under a given path. All methods accept absolute paths
// or paths relative to current working directory. FileSystem will ensure they
// are under 'root' directory.
//
// It can also accept a path to a directory to put "trash" into: files that
// can't be removed because there are some processes keeping lock on them.
// This is useful on Windows when replacing running executables. The trash
// directory must be on the same disk as the root directory.
//
// It 'trash' is empty string, the trash directory will be created under
// 'root'.
func NewFileSystem(root, trash string) FileSystem {
var err error
if root, err = filepath.Abs(root); err != nil {
return &fsImplErr{err}
}
if trash != "" {
if trash, err = filepath.Abs(trash); err != nil {
return &fsImplErr{err}
}
} else {
trash = filepath.Join(root, ".cipd_trash")
}
return &fsImpl{root: root, trash: trash}
}
// EnsureFile creates a file with the given content.
// It will create full directory path to the file if necessary.
func EnsureFile(ctx context.Context, fs FileSystem, path string, content io.Reader) error {
return fs.EnsureFile(ctx, path, func(f *os.File) error {
_, err := io.Copy(f, content)
return err
})
}
// fsImplErr implements FileSystem by returning given error from all methods.
type fsImplErr struct {
err error
}
func (f *fsImplErr) Root() string { return "" }
func (f *fsImplErr) CaseSensitive() (bool, error) { return false, f.err }
func (f *fsImplErr) CwdRelToAbs(string) (string, error) { return "", f.err }
func (f *fsImplErr) RootRelToAbs(string) (string, error) { return "", f.err }
func (f *fsImplErr) OpenFile(string) (*os.File, error) { return nil, f.err }
func (f *fsImplErr) Stat(context.Context, string) (os.FileInfo, error) { return nil, f.err }
func (f *fsImplErr) Lstat(context.Context, string) (os.FileInfo, error) { return nil, f.err }
func (f *fsImplErr) EnsureDirectory(context.Context, string) (string, error) { return "", f.err }
func (f *fsImplErr) EnsureSymlink(context.Context, string, string) error { return f.err }
func (f *fsImplErr) EnsureFile(context.Context, string, func(*os.File) error) error { return f.err }
func (f *fsImplErr) EnsureFileGone(context.Context, string) error { return f.err }
func (f *fsImplErr) EnsureDirectoryGone(context.Context, string) error { return f.err }
func (f *fsImplErr) Replace(context.Context, string, string) error { return f.err }
func (f *fsImplErr) CleanupTrash(context.Context) {}
/// Implementation.
type fsImpl struct {
root string
trash string
once sync.Once
caseSens bool
caseSensErr error
}
func (f *fsImpl) Root() string {
return f.root
}
func (f *fsImpl) CaseSensitive() (bool, error) {
f.once.Do(func() {
f.caseSens, f.caseSensErr = func() (sens bool, err error) {
tmp, err := ioutil.TempFile(f.root, ".test_case.*.tmp")
if err != nil {
return false, errors.Annotate(err, "creating a file to test case-sensitivity of %q", f.root).Err()
}
tmp.Close() // for Windows, it may act funny with open files
defer func() {
if rmErr := os.Remove(tmp.Name()); err == nil && rmErr != nil {
err = errors.Annotate(rmErr, "removing the file during case-sensitivity test of %q", f.root).Err()
}
}()
altName := filepath.Join(f.root, strings.ToUpper(filepath.Base(tmp.Name())))
switch _, err = os.Stat(altName); {
case err == nil:
return false, nil // case-insensitive
case os.IsNotExist(err):
return true, nil // case-sensitive
default:
return false, errors.Annotate(err, "stat'ing file when testing case-sensitivity of %q", f.root).Err()
}
}()
})
return f.caseSens, f.caseSensErr
}
func (f *fsImpl) CwdRelToAbs(p string) (string, error) {
p, err := filepath.Abs(p)
if err != nil {
return "", err
}
rel, err := filepath.Rel(f.root, p)
if err != nil {
return "", err
}
rel = filepath.ToSlash(rel)
if rel == ".." || strings.HasPrefix(rel, "../") {
return "", errors.Reason("path %q is outside of %q", p, f.root).Err()
}
return p, nil
}
func (f *fsImpl) RootRelToAbs(p string) (string, error) {
if filepath.IsAbs(p) {
return f.CwdRelToAbs(p)
}
return f.CwdRelToAbs(filepath.Join(f.root, p))
}
func (f *fsImpl) OpenFile(p string) (*os.File, error) {
p, err := f.CwdRelToAbs(p)
if err != nil {
return nil, err
}
return openFile(p)
}
func (f *fsImpl) Stat(ctx context.Context, p string) (os.FileInfo, error) {
p, err := f.CwdRelToAbs(p)
if err != nil {
return nil, err
}
return os.Stat(p)
}
func (f *fsImpl) Lstat(ctx context.Context, p string) (os.FileInfo, error) {
p, err := f.CwdRelToAbs(p)
if err != nil {
return nil, err
}
return os.Lstat(p)
}
func (f *fsImpl) EnsureDirectory(ctx context.Context, path string) (string, error) {
path, err := f.CwdRelToAbs(path)
if err != nil {
return "", err
}
err = os.MkdirAll(path, 0777)
// ENOTDIR/ERROR_DIRECTORY happens if 'path' or some of its parents is a not a
// directory. We want to delete such file so 'path' can be built up to be
// a directory.
//
// In this scenario IsNotExist is specifically for Windows, which for whatever
// reason returns it sometimes instead of ERROR_DIRECTORY.
//
// EEXIST happens when 'path' already exists and it is a broken symlink. This
// is due to the internal logic of MkdirAll, which confuses ENOENT from
// os.Stat due to a broken symlink with "ah, the directory is missing and we
// should create it". It then proceeds to calling `mkdir`, which fails with
// EEXIST (because there's the symlink there already). os.MkdirAll then
// assumes it is an existing directory, and double checks this with os.Lstat
// (NOT os.Stat, who knows why), discovering it is in fact not a directory,
// but a symlink. At this point it gives up and returns the error from `mkdir`
// (i.e. EEXIST).
if os.IsNotExist(err) || IsNotDir(err) || os.IsExist(err) {
cur := path
for {
// Note: this returns nil error for broken symlinks.
fi, err := os.Lstat(cur)
// If 'cur' doesn't exist yet or some of its parent is not a directory, go
// up until we find this non-directory.
if os.IsNotExist(err) || IsNotDir(err) {
dir := filepath.Dir(cur)
if dir == cur {
break // reached the root, MkdirAll must succeed then
}
cur = dir
continue
}
// Some fatal error in Lstat (most likely permissions)?
if err != nil {
return "", err
}
// Found no non-directories in 'path', MkdirAll mush succeed then.
if fi.IsDir() {
break
}
// Found a non-directory element! Delete it and try MkdirAll again, which
// should succeed now.
if err := f.EnsureFileGone(ctx, cur); err != nil {
return "", err
}
break
}
// Try again. Once.
err = os.MkdirAll(path, 0777)
}
if err != nil {
return "", err
}
return path, nil
}
func (f *fsImpl) EnsureFile(ctx context.Context, path string, write func(*os.File) error) error {
path, err := f.CwdRelToAbs(path)
if err != nil {
return err
}
if _, err := f.EnsureDirectory(ctx, filepath.Dir(path)); err != nil {
return err
}
temp := tempFileName(path)
// Make sure to cleanup garbage on errors or panics.
ok := false
defer func() {
if !ok {
if err := os.Remove(temp); err != nil && !os.IsNotExist(err) {
logging.Warningf(ctx, "fs: failed to remove %q: %s", temp, err)
}
}
}()
// Create a temp file with new content.
if err := createFile(temp, write); err != nil {
return err
}
// Replace the current file (if there's one) with a new one. Use nuclear
// version (f.Replace) instead of simple mostlyAtomicRename to handle various
// edge cases handled by the nuclear version (e.g. replacing a non-empty
// directory).
if err := f.Replace(ctx, temp, path); err != nil {
return err
}
ok = true
return nil
}
func (f *fsImpl) EnsureSymlink(ctx context.Context, path string, target string) error {
path, err := f.CwdRelToAbs(path)
if err != nil {
return err
}
if existing, _ := os.Readlink(path); existing == target {
return nil
}
if _, err := f.EnsureDirectory(ctx, filepath.Dir(path)); err != nil {
return err
}
// Create a new symlink file, can't modify existing one in place.
temp := tempFileName(path)
if err := os.Symlink(target, temp); err != nil {
return err
}
// Replace the current symlink with a new one. Use nuclear version (f.Replace)
// instead of simple mostlyAtomicRename to handle various edge cases handled
// by the nuclear version (e.g. replacing a non-empty directory).
if err := f.Replace(ctx, temp, path); err != nil {
if err2 := os.Remove(temp); err2 != nil && !os.IsNotExist(err2) {
logging.Warningf(ctx, "fs: failed to remove %q: %s", temp, err2)
}
return err
}
return nil
}
func (f *fsImpl) EnsureFileGone(ctx context.Context, path string) error {
path, err := f.CwdRelToAbs(path)
if err != nil {
return err
}
switch err = os.Remove(path); {
case err == nil:
return nil // removed
case os.IsNotExist(err):
return nil // didn't exist
case IsNotDir(err):
return nil // "path" is e.g. "a/b/c" where "a/b" is a file, not a directory
case IsNotEmpty(err):
return err // refuse to delete non-empty directories
default:
// Otherwise assume it's a locked file and just move it to trash.
if _, err2 := f.moveToTrash(ctx, path); err2 != nil {
return err // prefer the original error
}
logging.Debugf(ctx, "fs: trashed %q instead of removing: %s", path, err)
return nil
}
}
func (f *fsImpl) EnsureDirectoryGone(ctx context.Context, path string) error {
path, err := f.CwdRelToAbs(path)
if err != nil {
return err
}
// Make directory "disappear" instantly by renaming it first.
temp := tempFileName(path)
if err = mostlyAtomicRename(path, temp); err != nil {
if os.IsNotExist(err) {
return nil
}
logging.Warningf(ctx, "fs: failed to rename directory %q: %s", path, err)
return err
}
if err = os.RemoveAll(temp); err != nil {
logging.Warningf(ctx, "fs: failed to remove directory %q: %s", temp, err)
return err
}
return nil
}
func (f *fsImpl) Replace(ctx context.Context, oldpath, newpath string) error {
oldpath, err := f.CwdRelToAbs(oldpath)
if err != nil {
return err
}
newpath, err = f.CwdRelToAbs(newpath)
if err != nil {
return err
}
if oldpath == newpath {
return nil
}
// Make sure oldpath exists before doing heavy stuff.
if _, err = os.Lstat(oldpath); err != nil {
return err
}
// Make a parent directory of newpath.
if _, err = f.EnsureDirectory(ctx, filepath.Dir(newpath)); err != nil {
return err
}
// Try a regular move first. Replaces files atomically.
if err = mostlyAtomicRename(oldpath, newpath); err == nil {
return nil
}
// This code path is hit it two cases:
//
// 1. 'newpath' is a directory (empty or not).
// 2. 'newpath' is a locked running executable on Windows.
//
// We launch a retry loop in case there are multiple concurrent processes
// fighting for 'newpath'. We want to be the winner (i.e. the last), so we let
// them finish first by waiting a bit, and then replacing whatever they left.
waiter, cancel := retry.Waiter(ctx, "fs: lost a race when renaming", 10*time.Second)
defer cancel()
for {
// Try to move existing path away into the trash directory. If this fails
// in a weird way, mostlyAtomicRename most probably will also fail in a
// weird way, and the error will be properly propagated.
if trash, _ := f.moveToTrash(ctx, newpath); trash != "" {
// If 'newpath' was a directory, we can actually try to delete it as soon
// as possible (after exiting the retry loop). If this fails (e.g. if
// there are locked files), the trash is later opportunistically cleaned
// up in CleanupTrash.
defer f.cleanupTrashedFile(ctx, trash)
}
// 'newpath' now should be available.
switch err = mostlyAtomicRename(oldpath, newpath); {
case err == nil:
return nil
case IsNotEmpty(err):
break // existing non-empty directory
case IsNotDir(err):
break // "old argument names a directory and new argument names a non-directory"
case IsAccessDenied(err):
break // Windows-specific edge case
default:
// Failing in a weird way.
logging.Warningf(ctx, "fs: failed to rename(%q, %q): %s", oldpath, newpath, err)
return err
}
// We lost the race and someone managed to create 'newpath' before us. Let's
// try again a bit later. This will replace what they have created, screw
// them.
if err2 := waiter(); err2 != nil {
logging.Warningf(ctx, "fs: giving up trying to rename(%q, %q): %s", oldpath, newpath, err)
return err // prefer the original error
}
}
}
func (f *fsImpl) CleanupTrash(ctx context.Context) {
trashed, err := os.ReadDir(f.trash)
if err != nil {
if os.IsNotExist(err) {
return
}
logging.Warningf(ctx, "fs: cannot read the trash dir: %s", err)
return
}
if len(trashed) > 0 {
logging.Debugf(ctx, "fs: cleaning up trash (%d items)...", len(trashed))
}
undead := 0
for _, file := range trashed {
if f.cleanupTrashedFile(ctx, filepath.Join(f.trash, file.Name())) != nil {
undead++
}
}
switch {
case undead > 100:
logging.Warningf(ctx, "fs: too many undeletable files (%d) in the trash dir %q", undead, f.trash)
case undead == 0:
// Remove the empty directory too. Not a big deal if fails.
os.Remove(f.trash)
}
}
// moveToTrash is best-effort function to quickly cleanup a path.
//
// It accepts either files or directories.
//
// On non-Windows, it attempts to os.Remove(...) first. If it fails, it moves
// the path to a trash directory, where it eventually will be deleted. Note that
// either way we do O(1) number of syscalls.
//
// On Windows it always moves the file to the trash, since deleting a file on
// Windows doesn't really "free up" its name for another file: creating a file
// while there are still open handles to identically named file (even if it
// was unlinked from FS) results in ERROR_ACCESS_DENIED. Moving works though.
//
// Returns:
//
// ("", nil) if initial os.Remove(...) succeeded.
// ("<path in trash>", nil) if move to the trash succeeded.
// ("", non-nil) if move to the trash failed (this is also logged).
func (f *fsImpl) moveToTrash(ctx context.Context, path string) (string, error) {
if runtime.GOOS != "windows" {
if err := os.Remove(path); err == nil || os.IsNotExist(err) {
return "", nil
}
}
if err := os.MkdirAll(f.trash, 0777); err != nil {
logging.Warningf(ctx, "fs: can't create trash directory %q: %s", f.trash, err)
return "", err
}
trashed := filepath.Join(f.trash, pseudoRand())
if err := mostlyAtomicRename(path, trashed); err != nil {
if !os.IsNotExist(err) {
logging.Warningf(ctx, "fs: failed to rename(%q, %q) when trashing: %s", path, trashed, err)
}
return "", err
}
return trashed, nil
}
// cleanupTrashedFile is best-effort function to remove a trashed file or dir.
//
// Logs errors.
func (f *fsImpl) cleanupTrashedFile(ctx context.Context, path string) error {
if filepath.Dir(path) != f.trash {
return errors.Reason("not in the trash: %q", path).Err()
}
err := os.RemoveAll(path)
if err != nil {
logging.Debugf(ctx, "fs: failed to cleanup trashed file: %s", err)
}
return err
}
/// Internal stuff.
var (
lastUsedTime int64
lastUsedTimeLock sync.Mutex
)
// tempFileName returns "random enough" path in the same directory as a given
// path. It's not actively trying to be secure. Assumes that 'path' is not world
// writable (i.e. not /tmp).
//
// Doesn't check for existence of the file at the given path (e.g. there may be
// conflicts, but the probability should be small).
//
// TODO(vadimsh): Maybe we should change that? This is dangerous assumption.
// Simple Exists(...) check would reduce the likelihood of a conflict
// significantly in exchange for some modest performance impact.
func tempFileName(path string) string {
return filepath.Join(filepath.Dir(path), pseudoRand())
}
// pseudoRand returns "random enough" string that can be used in file system
// paths of temp files.
func pseudoRand() string {
ts := time.Now().UnixNano()
lastUsedTimeLock.Lock()
if ts <= lastUsedTime {
ts = lastUsedTime + 1
}
lastUsedTime = ts
lastUsedTimeLock.Unlock()
// Hash the state to get a smaller pseudorandom string.
h := sha256.New()
fmt.Fprintf(h, "%v_%v", os.Getpid(), ts)
sum := h.Sum(nil)
digest := base64.RawURLEncoding.EncodeToString(sum)
return digest[:12]
}
// TempDir is like ioutil.TempDir(dir, ""), but uses shorter path suffixes.
//
// Path length is constraint resource of Windows.
//
// Supposed to be used only in cases when the probability of a conflict is low
// (e.g. when 'dir' is some "private" directory, not global /tmp or something
// like that).
//
// Additionally, this allows you to pass mode (which will respect the process
// umask). To get ioutils.TempDir behavior, pass 0700 for the mode.
func TempDir(dir string, prefix string, mode os.FileMode) (name string, err error) {
for i := 0; i < 1000; i++ {
try := filepath.Join(dir, prefix+pseudoRand())
err = os.Mkdir(try, mode)
if os.IsExist(err) {
continue
}
if err == nil {
name = try
}
break
}
return
}
// createFile creates a file and calls the function to write file content.
//
// Does NOT cleanup the file if something fails midway.
func createFile(path string, write func(*os.File) error) (err error) {
file, err := os.Create(path)
if err != nil {
return err
}
defer func() {
closeErr := file.Close()
if err == nil {
err = closeErr
}
}()
return write(file)
}