blob: 0190e3a188922dfbbe188aed348b0e5f3516a848 [file] [log] [blame]
// Copyright 2014 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"
"io"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/cipd/common/cipderr"
)
const (
// SiteServiceDir is a name of the directory inside an installation root
// reserved for cipd stuff.
SiteServiceDir = ".cipd"
)
// File defines a single file to be added or extracted from a package.
//
// All paths are slash separated (including symlink targets).
//
// Errors are annotated with error codes.
type File interface {
// Name returns slash separated file path relative to a package root
//
// For example "dir/dir/file".
Name() string
// Size returns size of the file. 0 for symlinks.
Size() uint64
// Executable returns true if the file is executable.
//
// Only used for Linux\Mac archives. false for symlinks.
Executable() bool
// Writable returns true if the file is user-writable.
Writable() bool
// ModTime returns modification time of the file. Zero value means no mtime is
// recorded.
ModTime() time.Time
// Symlink returns true if the file is a symlink.
Symlink() bool
// SymlinkTarget return a path the symlink is pointing to.
SymlinkTarget() (string, error)
// WinAttrs returns the windows attributes, if any.
WinAttrs() WinAttrs
// Open opens the regular file for reading.
//
// Returns error for symlink files.
Open() (io.ReadCloser, error)
}
// WinAttrs represents the extra file attributes for windows.
type WinAttrs uint32
// These are the valid WinAttrs values. They may be OR'd together to form
// a mask. These match the windows GetFileAttributes values.
const (
WinAttrHidden WinAttrs = 0x2
WinAttrSystem WinAttrs = 0x4
WinAttrsAll WinAttrs = WinAttrHidden | WinAttrSystem
)
func (w WinAttrs) String() string {
ret := ""
if w&WinAttrHidden != 0 {
ret += "H"
}
if w&WinAttrSystem != 0 {
ret += "S"
}
return ret
}
// CreateFileOptions provides arguments to Destination.CreateFile().
type CreateFileOptions struct {
// Executable makes the file executable.
Executable bool
// Writable makes the file user-writable.
Writable bool
// ModTime, when non-zero, sets the mtime of the file.
ModTime time.Time
// WinAttrs is used on Windows.
WinAttrs WinAttrs
}
// Destination knows how to create files and symlink when extracting a package.
//
// All paths are slash separated and relative to the destination root.
//
// 'CreateFile' and 'CreateSymlink' can be called concurrently.
//
// Errors are annotated with error codes.
type Destination interface {
// CreateFile opens a writer to extract some package file to.
CreateFile(ctx context.Context, name string, opts CreateFileOptions) (io.WriteCloser, error)
// CreateSymlink creates a symlink (with absolute or relative target).
CreateSymlink(ctx context.Context, name string, target string) error
}
// TransactionalDestination is a destination that supports transactions.
//
// It provides 'Begin' and 'End' methods: all calls to 'CreateFile' and
// 'CreateSymlink' should happen between them. No changes are really applied
// until End(true) is called. A call to End(false) discards any pending changes.
//
// Errors are annotated with error codes.
type TransactionalDestination interface {
Destination
// Begin initiates a new write transaction.
Begin(ctx context.Context) error
// End finalizes package extraction (commit or rollback, based on success).
End(ctx context.Context, success bool) error
}
////////////////////////////////////////////////////////////////////////////////
// File system source.
type fileSystemFile struct {
absPath string
name string
size uint64
executable bool
writable bool
modtime time.Time
symlinkTarget string
winAttrs WinAttrs
}
func (f *fileSystemFile) Name() string { return f.name }
func (f *fileSystemFile) Size() uint64 { return f.size }
func (f *fileSystemFile) Executable() bool { return f.executable }
func (f *fileSystemFile) Writable() bool { return f.writable }
func (f *fileSystemFile) ModTime() time.Time { return f.modtime }
func (f *fileSystemFile) Symlink() bool { return f.symlinkTarget != "" }
func (f *fileSystemFile) WinAttrs() WinAttrs { return f.winAttrs }
func (f *fileSystemFile) SymlinkTarget() (string, error) {
if f.symlinkTarget != "" {
return f.symlinkTarget, nil
}
return "", errors.Reason("%q: not a symlink", f.Name()).Tag(cipderr.IO).Err()
}
func (f *fileSystemFile) Open() (io.ReadCloser, error) {
if f.Symlink() {
return nil, errors.Reason("%q: opening a symlink is not allowed", f.Name()).Tag(cipderr.IO).Err()
}
r, err := os.Open(f.absPath)
if err != nil {
return nil, errors.Annotate(err, "opening %q", f.Name()).Tag(cipderr.IO).Err()
}
return r, nil
}
// ScanFilter is predicate used by ScanFileSystem to decide what files to skip.
//
// It receives a path being judged and returns true if it should be skipped.
// The path is a filepath relative to the starting scanning directory (i.e.
// 'dir' in ScanFileSystem).
type ScanFilter func(rel string) bool
// ScanOptions specify which file properties to preserve in the archive.
type ScanOptions struct {
// PreserveModTime when true saves the file's modification time.
PreserveModTime bool
// PreserveWritable when true saves the file's writable bit for either user,
// group or world (mask 0222), and converts it to user-only writable bit (mask
// 0200).
PreserveWritable bool
}
// ScanFileSystem returns all files in some file system directory in
// alphabetical order.
//
// It scans 'dir' path, returning File objects that have paths relative to
// 'root'. Returns only files, skipping directory entries (i.e. empty
// directories are completely "invisible").
//
// It also skips files and directories for which 'exclude(path relative to dir)'
// returns true. It also always skips <root>/<SiteServiceDir>, but follows
// symlinks that point there.
//
// Symlinks are preserved (see Symlink() method of File interface), but the
// following rules apply:
// - Relative symlinks pointing outside of the root are forbidden.
// - An absolute symlink with the target outside the root is kept as such.
// - An absolute symlink with the target within the root becomes relative.
//
// If 'dir' is a symlink itself, it is emitted as such (with all above rules
// applying), except when 'dir == root' (lexicographically, as cleaned absolute
// paths). In that case the symlink is followed and whatever it points to is
// used as new 'dir' and 'root'. This is completely transparent in the output
// though, since File uses only relative paths. Without this exception using
// a symlink as a package root leads to very surprising errors.
//
// Errors are annotated with error codes.
func ScanFileSystem(dir string, root string, exclude ScanFilter, scanOpts ScanOptions) ([]File, error) {
dir, err := filepath.Abs(filepath.Clean(dir))
if err != nil {
return nil, errors.Annotate(err, "bad input path").Tag(cipderr.BadArgument).Err()
}
root, err = filepath.Abs(filepath.Clean(root))
if err != nil {
return nil, errors.Annotate(err, "bad root path").Tag(cipderr.BadArgument).Err()
}
// If we are scanning 'root' directly, it doesn't matter if it is itself
// a symlink. We should never try to package it as such, it is useless.
if dir == root {
resolved, err := filepath.EvalSymlinks(dir)
if err != nil {
return nil, errors.Annotate(err, "resolving symlinks in the input dir path").Tag(cipderr.IO).Err()
}
dir = resolved
root = resolved
}
if !IsSubpath(dir, root) {
return nil, errors.Reason("the scanned directory must be under the root directory").Tag(cipderr.BadArgument).Err()
}
files := []File{}
svcDir := filepath.Join(root, SiteServiceDir)
err = filepath.Walk(dir, func(abs string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip the SiteServiceDir entirely.
if abs == svcDir {
return filepath.SkipDir
}
// Apply the exclusion filter. Also skip files for which filepath.Rel
// returns an error.
if exclude != nil && abs != dir {
if rel, err := filepath.Rel(dir, abs); err != nil || exclude(rel) {
if info.Mode().IsDir() {
return filepath.SkipDir
}
return nil
}
}
// Skip exotic file types, we deal only with regular files and symlinks.
if info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
f, err := WrapFile(abs, root, &info, scanOpts)
if err != nil {
return err
}
files = append(files, f)
}
return nil
})
if err != nil {
if cipderr.ToCode(err) != cipderr.Unknown {
return nil, err // already annotated, do not clobber the code
}
return nil, errors.Annotate(err, "scanning input directory").Tag(cipderr.IO).Err()
}
return files, nil
}
func getWritable(info os.FileInfo, opts ScanOptions) bool {
return opts.PreserveWritable && (info.Mode().Perm()&0222) != 0
}
func getModTime(info os.FileInfo, opts ScanOptions) time.Time {
if !opts.PreserveModTime {
return time.Time{}
}
return info.ModTime()
}
// WrapFile constructs File object for some file in the file system, specified
// by its native absolute path 'abs' (subpath of 'root', also specified as
// a native absolute path).
//
// Returned File object has path relative to 'root'. If fileInfo is given, it
// will be used to grab file mode and size, otherwise os.Lstat will be used to
// get it.
//
// Recognizes symlinks.
//
// Errors are annotated with error codes.
func WrapFile(abs string, root string, fileInfo *os.FileInfo, scanOpts ScanOptions) (File, error) {
if !filepath.IsAbs(abs) {
return nil, errors.Reason("expecting absolute file path, got %q", abs).Tag(cipderr.BadArgument).Err()
}
if !filepath.IsAbs(root) {
return nil, errors.Reason("expecting absolute root path, got %q", root).Tag(cipderr.BadArgument).Err()
}
if !IsSubpath(abs, root) {
return nil, errors.Reason("path %q is not under %q", abs, root).Tag(cipderr.BadArgument).Err()
}
var info os.FileInfo
if fileInfo == nil {
// Use Lstat to NOT follow symlinks (as os.Stat does).
var err error
info, err = os.Lstat(abs)
if err != nil {
return nil, errors.Annotate(err, "checking file info").Tag(cipderr.IO).Err()
}
} else {
info = *fileInfo
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return nil, errors.Annotate(err, "getting relative path").Tag(cipderr.IO).Err()
}
// Recognize symlinks as such, convert target to relative path, if needed.
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(abs)
if err != nil {
return nil, errors.Annotate(err, "reading symlink target").Tag(cipderr.IO).Err()
}
targetAbs := ""
if filepath.IsAbs(target) {
targetAbs = target
// Convert absolute path pointing somewhere in "root" into a path
// relative to the symlink file itself. Store other absolute paths as
// they are. For example, it allows to package virtual env directory
// that symlinks python binary from /usr/bin.
if IsSubpath(target, root) {
target, err = filepath.Rel(filepath.Dir(abs), target)
if err != nil {
return nil, errors.Annotate(err, "converting symlink target path to be relative").Tag(cipderr.IO).Err()
}
}
} else {
// Only relative paths that do not go outside "root" are allowed.
// A package must not depend on its installation path.
targetAbs = filepath.Clean(filepath.Join(filepath.Dir(abs), target))
if !IsSubpath(targetAbs, root) {
return nil, errors.Reason(
"invalid symlink %q: a relative symlink pointing to a file outside of the package root", rel).Tag(cipderr.BadArgument).Err()
}
}
// Symlinks with targets within SiteServiceDir get resolved as normal files.
if IsSubpath(targetAbs, filepath.Join(root, SiteServiceDir)) {
abs = targetAbs
info, err = os.Stat(abs)
if err != nil {
return nil, errors.Annotate(err, "checking symlink target").Tag(cipderr.IO).Err()
}
} else {
return &fileSystemFile{
absPath: abs,
name: filepath.ToSlash(rel),
writable: getWritable(info, scanOpts),
modtime: getModTime(info, scanOpts),
symlinkTarget: filepath.ToSlash(target),
}, nil
}
}
// Regular file.
if info.Mode().IsRegular() {
attrs, err := getWinFileAttributes(info)
if err != nil {
return nil, errors.Annotate(err, "getting win file attributes").Tag(cipderr.IO).Err()
}
return &fileSystemFile{
absPath: abs,
name: filepath.ToSlash(rel),
size: uint64(info.Size()),
executable: (info.Mode().Perm() & 0111) != 0,
writable: getWritable(info, scanOpts),
modtime: getModTime(info, scanOpts),
winAttrs: attrs,
}, nil
}
return nil, errors.Reason("%q: not a regular file or symlink", abs).Tag(cipderr.BadArgument).Err()
}
////////////////////////////////////////////////////////////////////////////////
// Destination implementation based on FileSystem.
// fsDest implements Destination on top of some existing file system directory.
type fsDest struct {
// FileSystem implementation to use for manipulating files.
fs FileSystem
// Where to create all files in, must be under 'fs' root.
dest string
// If true, CreateFile will use atomic rename trick to drop files.
atomic bool
// Currently open files.
lock sync.RWMutex
openFiles map[string]*os.File
}
// ExistingDestination returns an object that knows how to create files in an
// existing directory in the file system.
//
// Will use a provided FileSystem object to operate on files if given, otherwise
// uses a default one. If FileSystem is provided, dest must be in a subdirectory
// of the given FileSystem root.
//
// Note that the returned object doesn't support transactional semantics, since
// transactionally writing a bunch of files into an existing directory is hard.
//
// See NewDestination for writing files into a completely new directory. This
// method supports transactions.
func ExistingDestination(dest string, fs FileSystem) Destination {
if fs == nil {
fs = NewFileSystem(filepath.Dir(dest), "")
}
return &fsDest{
fs: fs,
dest: dest,
atomic: true, // note: txnFSDest unsets this, see Begin
openFiles: map[string]*os.File{},
}
}
// numOpenFiles returns a number of currently open files.
//
// Used by txnFSDest to make sure all files are closed before finalizing the
// transaction.
func (d *fsDest) numOpenFiles() (n int) {
d.lock.RLock()
n = len(d.openFiles)
d.lock.RUnlock()
return
}
// prepareFilePath performs steps common to CreateFile and CreateSymlink.
//
// It does some validation, expands "name" to an absolute path and creates
// parent directories for a future file. Returns absolute path where the file
// should be put.
func (d *fsDest) prepareFilePath(ctx context.Context, name string) (string, error) {
path := filepath.Clean(filepath.Join(d.dest, filepath.FromSlash(name)))
if !IsSubpath(path, d.dest) {
return "", errors.Reason("%q: invalid relative file name", name).Tag(cipderr.BadArgument).Err()
}
if _, err := d.fs.EnsureDirectory(ctx, filepath.Dir(path)); err != nil {
return "", errors.Annotate(err, "creating parent dir for a file").Tag(cipderr.IO).Err()
}
return path, nil
}
func (d *fsDest) CreateFile(ctx context.Context, name string, opts CreateFileOptions) (io.WriteCloser, error) {
path, err := d.prepareFilePath(ctx, name)
if err != nil {
return nil, err
}
d.lock.RLock()
_, ok := d.openFiles[path]
d.lock.RUnlock()
if ok {
return nil, errors.Reason("%q: already open", name).Tag(cipderr.IO).Err()
}
// Let the umask trim the file mode.
var mode os.FileMode
if opts.Executable {
if runtime.GOOS == "windows" {
logging.Warningf(ctx, "[data-loss] ignoring +x on %q", name)
}
mode = 0555
} else {
mode = 0444
}
if opts.Writable {
mode |= 0222
}
if opts.WinAttrs != 0 && runtime.GOOS != "windows" {
logging.Warningf(ctx, "[data-loss] ignoring +%s on %q", opts.WinAttrs, name)
}
// In atomic mode we write to a temp file and then rename it into 'path',
// in non-atomic mode we just directly write into 'path' (perhaps overwriting
// it).
writeTo := path
flags := os.O_CREATE | os.O_WRONLY
if d.atomic {
writeTo = tempFileName(path)
flags |= os.O_EXCL // for improbable case of collision on temp file name
}
file, err := os.OpenFile(writeTo, flags, mode)
if !d.atomic && os.IsPermission(err) {
// Attempting to open an existing read-only file in non-atomic mode. Delete
// it and try again. We are overriding it anyway.
d.fs.EnsureFileGone(ctx, writeTo)
file, err = os.OpenFile(writeTo, flags, mode)
}
if err != nil {
return nil, errors.Annotate(err, "creating destination file").Tag(cipderr.IO).Err()
}
// Close and delete (if it was a temp) on failures. Best effort.
disarm := false
defer func() {
if !disarm {
if clErr := file.Close(); clErr != nil {
logging.Warningf(ctx, "Failed to close %s when cleaning up: %s", writeTo, clErr)
}
if d.atomic {
// Nuke the unfinished temp file. The error is logged inside.
d.fs.EnsureFileGone(ctx, writeTo)
}
}
}()
if err := setWinFileAttributes(writeTo, opts.WinAttrs); err != nil {
return nil, errors.Annotate(err, "setting win attributes").Tag(cipderr.IO).Err()
}
d.lock.Lock()
defer d.lock.Unlock()
if _, ok := d.openFiles[path]; ok {
return nil, errors.Reason("race condition when creating %s", name).Tag(cipderr.IO).Err()
}
d.openFiles[path] = file
disarm = true // skip the defer, we want to keep the file open
return &hookedWriter{
Writer: file,
closeCb: func() (err error) {
d.lock.Lock()
delete(d.openFiles, path)
d.lock.Unlock()
err = file.Close()
if !opts.ModTime.IsZero() {
if chErr := os.Chtimes(writeTo, opts.ModTime, opts.ModTime); chErr != nil {
logging.Warningf(ctx, "[data-loss] cannot set mtime for %s: %s", path, chErr)
}
}
// In atomic mode need to rename the temp file into its final name.
if writeTo != path {
replErr := d.fs.Replace(ctx, writeTo, path)
if err == nil { // prefer to return errors from Close()
err = replErr
}
}
if err != nil {
err = errors.Annotate(err, "closing the destination file").Tag(cipderr.IO).Err()
}
return
},
}, nil
}
type hookedWriter struct {
io.Writer
closeCb func() error
}
func (w *hookedWriter) Close() error { return w.closeCb() }
func (d *fsDest) CreateSymlink(ctx context.Context, name string, target string) error {
path, err := d.prepareFilePath(ctx, name)
if err != nil {
return err
}
// Forbid relative symlinks to files outside of the destination root.
target = filepath.FromSlash(target)
if !filepath.IsAbs(target) {
targetAbs := filepath.Clean(filepath.Join(filepath.Dir(path), target))
if !IsSubpath(targetAbs, d.dest) {
return errors.Reason("%q: relative symlink is pointing outside of the destination dir", name).Tag(cipderr.BadArgument).Err()
}
}
if err := d.fs.EnsureSymlink(ctx, path, target); err != nil {
return errors.Annotate(err, "creating symlink").Tag(cipderr.IO).Err()
}
return nil
}
////////////////////////////////////////////////////////////////////////////////
// TransactionalDestination implementation based on FileSystem.
// txnFSDest implements TransactionalDestination on top of a file system using
// "atomically rename a directory" trick for transactionality.
type txnFSDest struct {
// FileSystem implementation to use for manipulating files.
fs FileSystem
// The final destination directory to be created when End(true) is called.
dest string
// The underlying FS destination which will be renamed into 'dest' in End().
//
// Setup to be a temp directory in Begin().
fsDest *fsDest
}
// NewDestination returns a destination in the file system (a new directory)
// to extract a package to.
//
// Will use a provided FileSystem object to operate on files if given, otherwise
// use a default one. If FileSystem is provided, dir must be in a subdirectory
// of the given FileSystem root.
func NewDestination(dest string, fs FileSystem) TransactionalDestination {
if fs == nil {
fs = NewFileSystem(filepath.Dir(dest), "")
}
return &txnFSDest{fs: fs, dest: dest}
}
func (d *txnFSDest) Begin(ctx context.Context) error {
if d.fsDest != nil {
return errors.Reason("destination is already open").Tag(cipderr.BadArgument).Err()
}
// Ensure the parent directory of the destination directory exists, to be able
// to create a temp subdir there.
var err error
if d.dest, err = d.fs.CwdRelToAbs(d.dest); err != nil {
return errors.Annotate(err, "bad destination path").Tag(cipderr.BadArgument).Err()
}
if _, err := d.fs.EnsureDirectory(ctx, filepath.Dir(d.dest)); err != nil {
return errors.Annotate(err, "creating the destination path").Tag(cipderr.IO).Err()
}
// Create the staging directory, on the same level as the destination
// directory, so it can just be renamed into d.dest on completion. Let umask
// trim the permissions appropriately. Note that it is not really a "private"
// temp dir, since it will be eventually renamed into the "public" destination
// that should be readable to everyone (sans umask).
tempDir, err := TempDir(filepath.Dir(d.dest), "", 0777)
if err != nil {
return errors.Annotate(err, "creating temporary destination dir").Tag(cipderr.IO).Err()
}
// Setup a non-txn destination that extracts into the staging directory. Note
// that tempDir is totally owned by txnFSDest, and we ensure there are no
// concurrent writes to the same destination file, so we disable 'atomic' mode
// as it is unnecessary (and costs a bunch of syscalls per file).
d.fsDest = ExistingDestination(tempDir, d.fs).(*fsDest)
d.fsDest.atomic = false
return nil
}
func (d *txnFSDest) CreateFile(ctx context.Context, name string, opts CreateFileOptions) (io.WriteCloser, error) {
if d.fsDest == nil {
return nil, errors.Reason("destination is not open").Tag(cipderr.BadArgument).Err()
}
return d.fsDest.CreateFile(ctx, name, opts)
}
func (d *txnFSDest) CreateSymlink(ctx context.Context, name string, target string) error {
if d.fsDest == nil {
return errors.Reason("destination is not open").Tag(cipderr.BadArgument).Err()
}
return d.fsDest.CreateSymlink(ctx, name, target)
}
func (d *txnFSDest) End(ctx context.Context, success bool) error {
if d.fsDest == nil {
return errors.Reason("destination is not open").Tag(cipderr.BadArgument).Err()
}
if leaking := d.fsDest.numOpenFiles(); leaking != 0 {
return errors.Reason("not all files were closed (leaking %d files)", leaking).Tag(cipderr.IO).Err()
}
// Clean up the temp dir and the state no matter what. On success it is
// already gone (renamed into d.dest).
defer func() {
d.fs.EnsureDirectoryGone(ctx, d.fsDest.dest)
d.fsDest = nil
}()
if success {
if err := d.fs.Replace(ctx, d.fsDest.dest, d.dest); err != nil {
return errors.Annotate(err, "moving temp destination directory into its final location").Tag(cipderr.IO).Err()
}
}
// Let the defer clean the garbage left in fsDest.
return nil
}