docker/docker

View on GitHub
pkg/archive/copy.go

Summary

Maintainability
A
1 hr
Test Coverage
package archive // import "github.com/docker/docker/pkg/archive"

import (
    "archive/tar"
    "context"
    "errors"
    "io"
    "os"
    "path/filepath"
    "strings"

    "github.com/containerd/log"
    "github.com/docker/docker/pkg/system"
)

// Errors used or returned by this file.
var (
    ErrNotDirectory      = errors.New("not a directory")
    ErrDirNotExists      = errors.New("no such directory")
    ErrCannotCopyDir     = errors.New("cannot copy directory")
    ErrInvalidCopySource = errors.New("invalid copy source content")
)

// PreserveTrailingDotOrSeparator returns the given cleaned path (after
// processing using any utility functions from the path or filepath stdlib
// packages) and appends a trailing `/.` or `/` if its corresponding  original
// path (from before being processed by utility functions from the path or
// filepath stdlib packages) ends with a trailing `/.` or `/`. If the cleaned
// path already ends in a `.` path segment, then another is not added. If the
// clean path already ends in a path separator, then another is not added.
func PreserveTrailingDotOrSeparator(cleanedPath string, originalPath string) string {
    // Ensure paths are in platform semantics
    cleanedPath = normalizePath(cleanedPath)
    originalPath = normalizePath(originalPath)

    if !specifiesCurrentDir(cleanedPath) && specifiesCurrentDir(originalPath) {
        if !hasTrailingPathSeparator(cleanedPath) {
            // Add a separator if it doesn't already end with one (a cleaned
            // path would only end in a separator if it is the root).
            cleanedPath += string(filepath.Separator)
        }
        cleanedPath += "."
    }

    if !hasTrailingPathSeparator(cleanedPath) && hasTrailingPathSeparator(originalPath) {
        cleanedPath += string(filepath.Separator)
    }

    return cleanedPath
}

// assertsDirectory returns whether the given path is
// asserted to be a directory, i.e., the path ends with
// a trailing '/' or `/.`, assuming a path separator of `/`.
func assertsDirectory(path string) bool {
    return hasTrailingPathSeparator(path) || specifiesCurrentDir(path)
}

// hasTrailingPathSeparator returns whether the given
// path ends with the system's path separator character.
func hasTrailingPathSeparator(path string) bool {
    return len(path) > 0 && path[len(path)-1] == filepath.Separator
}

// specifiesCurrentDir returns whether the given path specifies
// a "current directory", i.e., the last path segment is `.`.
func specifiesCurrentDir(path string) bool {
    return filepath.Base(path) == "."
}

// SplitPathDirEntry splits the given path between its directory name and its
// basename by first cleaning the path but preserves a trailing "." if the
// original path specified the current directory.
func SplitPathDirEntry(path string) (dir, base string) {
    cleanedPath := filepath.Clean(filepath.FromSlash(path))

    if specifiesCurrentDir(path) {
        cleanedPath += string(os.PathSeparator) + "."
    }

    return filepath.Dir(cleanedPath), filepath.Base(cleanedPath)
}

// TarResource archives the resource described by the given CopyInfo to a Tar
// archive. A non-nil error is returned if sourcePath does not exist or is
// asserted to be a directory but exists as another type of file.
//
// This function acts as a convenient wrapper around TarWithOptions, which
// requires a directory as the source path. TarResource accepts either a
// directory or a file path and correctly sets the Tar options.
func TarResource(sourceInfo CopyInfo) (content io.ReadCloser, err error) {
    return TarResourceRebase(sourceInfo.Path, sourceInfo.RebaseName)
}

// TarResourceRebase is like TarResource but renames the first path element of
// items in the resulting tar archive to match the given rebaseName if not "".
func TarResourceRebase(sourcePath, rebaseName string) (content io.ReadCloser, err error) {
    sourcePath = normalizePath(sourcePath)
    if _, err = os.Lstat(sourcePath); err != nil {
        // Catches the case where the source does not exist or is not a
        // directory if asserted to be a directory, as this also causes an
        // error.
        return
    }

    // Separate the source path between its directory and
    // the entry in that directory which we are archiving.
    sourceDir, sourceBase := SplitPathDirEntry(sourcePath)
    opts := TarResourceRebaseOpts(sourceBase, rebaseName)

    log.G(context.TODO()).Debugf("copying %q from %q", sourceBase, sourceDir)
    return TarWithOptions(sourceDir, opts)
}

// TarResourceRebaseOpts does not preform the Tar, but instead just creates the rebase
// parameters to be sent to TarWithOptions (the TarOptions struct)
func TarResourceRebaseOpts(sourceBase string, rebaseName string) *TarOptions {
    filter := []string{sourceBase}
    return &TarOptions{
        Compression:      Uncompressed,
        IncludeFiles:     filter,
        IncludeSourceDir: true,
        RebaseNames: map[string]string{
            sourceBase: rebaseName,
        },
    }
}

// CopyInfo holds basic info about the source
// or destination path of a copy operation.
type CopyInfo struct {
    Path       string
    Exists     bool
    IsDir      bool
    RebaseName string
}

// CopyInfoSourcePath stats the given path to create a CopyInfo
// struct representing that resource for the source of an archive copy
// operation. The given path should be an absolute local path. A source path
// has all symlinks evaluated that appear before the last path separator ("/"
// on Unix). As it is to be a copy source, the path must exist.
func CopyInfoSourcePath(path string, followLink bool) (CopyInfo, error) {
    // normalize the file path and then evaluate the symbol link
    // we will use the target file instead of the symbol link if
    // followLink is set
    path = normalizePath(path)

    resolvedPath, rebaseName, err := ResolveHostSourcePath(path, followLink)
    if err != nil {
        return CopyInfo{}, err
    }

    stat, err := os.Lstat(resolvedPath)
    if err != nil {
        return CopyInfo{}, err
    }

    return CopyInfo{
        Path:       resolvedPath,
        Exists:     true,
        IsDir:      stat.IsDir(),
        RebaseName: rebaseName,
    }, nil
}

// CopyInfoDestinationPath stats the given path to create a CopyInfo
// struct representing that resource for the destination of an archive copy
// operation. The given path should be an absolute local path.
func CopyInfoDestinationPath(path string) (info CopyInfo, err error) {
    maxSymlinkIter := 10 // filepath.EvalSymlinks uses 255, but 10 already seems like a lot.
    path = normalizePath(path)
    originalPath := path

    stat, err := os.Lstat(path)

    if err == nil && stat.Mode()&os.ModeSymlink == 0 {
        // The path exists and is not a symlink.
        return CopyInfo{
            Path:   path,
            Exists: true,
            IsDir:  stat.IsDir(),
        }, nil
    }

    // While the path is a symlink.
    for n := 0; err == nil && stat.Mode()&os.ModeSymlink != 0; n++ {
        if n > maxSymlinkIter {
            // Don't follow symlinks more than this arbitrary number of times.
            return CopyInfo{}, errors.New("too many symlinks in " + originalPath)
        }

        // The path is a symbolic link. We need to evaluate it so that the
        // destination of the copy operation is the link target and not the
        // link itself. This is notably different than CopyInfoSourcePath which
        // only evaluates symlinks before the last appearing path separator.
        // Also note that it is okay if the last path element is a broken
        // symlink as the copy operation should create the target.
        var linkTarget string

        linkTarget, err = os.Readlink(path)
        if err != nil {
            return CopyInfo{}, err
        }

        if !system.IsAbs(linkTarget) {
            // Join with the parent directory.
            dstParent, _ := SplitPathDirEntry(path)
            linkTarget = filepath.Join(dstParent, linkTarget)
        }

        path = linkTarget
        stat, err = os.Lstat(path)
    }

    if err != nil {
        // It's okay if the destination path doesn't exist. We can still
        // continue the copy operation if the parent directory exists.
        if !os.IsNotExist(err) {
            return CopyInfo{}, err
        }

        // Ensure destination parent dir exists.
        dstParent, _ := SplitPathDirEntry(path)

        parentDirStat, err := os.Stat(dstParent)
        if err != nil {
            return CopyInfo{}, err
        }
        if !parentDirStat.IsDir() {
            return CopyInfo{}, ErrNotDirectory
        }

        return CopyInfo{Path: path}, nil
    }

    // The path exists after resolving symlinks.
    return CopyInfo{
        Path:   path,
        Exists: true,
        IsDir:  stat.IsDir(),
    }, nil
}

// PrepareArchiveCopy prepares the given srcContent archive, which should
// contain the archived resource described by srcInfo, to the destination
// described by dstInfo. Returns the possibly modified content archive along
// with the path to the destination directory which it should be extracted to.
func PrepareArchiveCopy(srcContent io.Reader, srcInfo, dstInfo CopyInfo) (dstDir string, content io.ReadCloser, err error) {
    // Ensure in platform semantics
    srcInfo.Path = normalizePath(srcInfo.Path)
    dstInfo.Path = normalizePath(dstInfo.Path)

    // Separate the destination path between its directory and base
    // components in case the source archive contents need to be rebased.
    dstDir, dstBase := SplitPathDirEntry(dstInfo.Path)
    _, srcBase := SplitPathDirEntry(srcInfo.Path)

    switch {
    case dstInfo.Exists && dstInfo.IsDir:
        // The destination exists as a directory. No alteration
        // to srcContent is needed as its contents can be
        // simply extracted to the destination directory.
        return dstInfo.Path, io.NopCloser(srcContent), nil
    case dstInfo.Exists && srcInfo.IsDir:
        // The destination exists as some type of file and the source
        // content is a directory. This is an error condition since
        // you cannot copy a directory to an existing file location.
        return "", nil, ErrCannotCopyDir
    case dstInfo.Exists:
        // The destination exists as some type of file and the source content
        // is also a file. The source content entry will have to be renamed to
        // have a basename which matches the destination path's basename.
        if len(srcInfo.RebaseName) != 0 {
            srcBase = srcInfo.RebaseName
        }
        return dstDir, RebaseArchiveEntries(srcContent, srcBase, dstBase), nil
    case srcInfo.IsDir:
        // The destination does not exist and the source content is an archive
        // of a directory. The archive should be extracted to the parent of
        // the destination path instead, and when it is, the directory that is
        // created as a result should take the name of the destination path.
        // The source content entries will have to be renamed to have a
        // basename which matches the destination path's basename.
        if len(srcInfo.RebaseName) != 0 {
            srcBase = srcInfo.RebaseName
        }
        return dstDir, RebaseArchiveEntries(srcContent, srcBase, dstBase), nil
    case assertsDirectory(dstInfo.Path):
        // The destination does not exist and is asserted to be created as a
        // directory, but the source content is not a directory. This is an
        // error condition since you cannot create a directory from a file
        // source.
        return "", nil, ErrDirNotExists
    default:
        // The last remaining case is when the destination does not exist, is
        // not asserted to be a directory, and the source content is not an
        // archive of a directory. It this case, the destination file will need
        // to be created when the archive is extracted and the source content
        // entry will have to be renamed to have a basename which matches the
        // destination path's basename.
        if len(srcInfo.RebaseName) != 0 {
            srcBase = srcInfo.RebaseName
        }
        return dstDir, RebaseArchiveEntries(srcContent, srcBase, dstBase), nil
    }
}

// RebaseArchiveEntries rewrites the given srcContent archive replacing
// an occurrence of oldBase with newBase at the beginning of entry names.
func RebaseArchiveEntries(srcContent io.Reader, oldBase, newBase string) io.ReadCloser {
    if oldBase == string(os.PathSeparator) {
        // If oldBase specifies the root directory, use an empty string as
        // oldBase instead so that newBase doesn't replace the path separator
        // that all paths will start with.
        oldBase = ""
    }

    rebased, w := io.Pipe()

    go func() {
        srcTar := tar.NewReader(srcContent)
        rebasedTar := tar.NewWriter(w)

        for {
            hdr, err := srcTar.Next()
            if err == io.EOF {
                // Signals end of archive.
                rebasedTar.Close()
                w.Close()
                return
            }
            if err != nil {
                w.CloseWithError(err)
                return
            }

            // srcContent tar stream, as served by TarWithOptions(), is
            // definitely in PAX format, but tar.Next() mistakenly guesses it
            // as USTAR, which creates a problem: if the newBase is >100
            // characters long, WriteHeader() returns an error like
            // "archive/tar: cannot encode header: Format specifies USTAR; and USTAR cannot encode Name=...".
            //
            // To fix, set the format to PAX here. See docker/for-linux issue #484.
            hdr.Format = tar.FormatPAX
            hdr.Name = strings.Replace(hdr.Name, oldBase, newBase, 1)
            if hdr.Typeflag == tar.TypeLink {
                hdr.Linkname = strings.Replace(hdr.Linkname, oldBase, newBase, 1)
            }

            if err = rebasedTar.WriteHeader(hdr); err != nil {
                w.CloseWithError(err)
                return
            }

            // Ignoring GoSec G110. See https://github.com/securego/gosec/pull/433
            // and https://cure53.de/pentest-report_opa.pdf, which recommends to
            // replace io.Copy with io.CopyN7. The latter allows to specify the
            // maximum number of bytes that should be read. By properly defining
            // the limit, it can be assured that a GZip compression bomb cannot
            // easily cause a Denial-of-Service.
            // After reviewing with @tonistiigi and @cpuguy83, this should not
            // affect us, because here we do not read into memory, hence should
            // not be vulnerable to this code consuming memory.
            //nolint:gosec // G110: Potential DoS vulnerability via decompression bomb (gosec)
            if _, err = io.Copy(rebasedTar, srcTar); err != nil {
                w.CloseWithError(err)
                return
            }
        }
    }()

    return rebased
}

// CopyResource performs an archive copy from the given source path to the
// given destination path. The source path MUST exist and the destination
// path's parent directory must exist.
func CopyResource(srcPath, dstPath string, followLink bool) error {
    var (
        srcInfo CopyInfo
        err     error
    )

    // Ensure in platform semantics
    srcPath = normalizePath(srcPath)
    dstPath = normalizePath(dstPath)

    // Clean the source and destination paths.
    srcPath = PreserveTrailingDotOrSeparator(filepath.Clean(srcPath), srcPath)
    dstPath = PreserveTrailingDotOrSeparator(filepath.Clean(dstPath), dstPath)

    if srcInfo, err = CopyInfoSourcePath(srcPath, followLink); err != nil {
        return err
    }

    content, err := TarResource(srcInfo)
    if err != nil {
        return err
    }
    defer content.Close()

    return CopyTo(content, srcInfo, dstPath)
}

// CopyTo handles extracting the given content whose
// entries should be sourced from srcInfo to dstPath.
func CopyTo(content io.Reader, srcInfo CopyInfo, dstPath string) error {
    // The destination path need not exist, but CopyInfoDestinationPath will
    // ensure that at least the parent directory exists.
    dstInfo, err := CopyInfoDestinationPath(normalizePath(dstPath))
    if err != nil {
        return err
    }

    dstDir, copyArchive, err := PrepareArchiveCopy(content, srcInfo, dstInfo)
    if err != nil {
        return err
    }
    defer copyArchive.Close()

    options := &TarOptions{
        NoLchown:             true,
        NoOverwriteDirNonDir: true,
    }

    return Untar(copyArchive, dstDir, options)
}

// ResolveHostSourcePath decides real path need to be copied with parameters such as
// whether to follow symbol link or not, if followLink is true, resolvedPath will return
// link target of any symbol link file, else it will only resolve symlink of directory
// but return symbol link file itself without resolving.
func ResolveHostSourcePath(path string, followLink bool) (resolvedPath, rebaseName string, err error) {
    if followLink {
        resolvedPath, err = filepath.EvalSymlinks(path)
        if err != nil {
            return
        }

        resolvedPath, rebaseName = GetRebaseName(path, resolvedPath)
    } else {
        dirPath, basePath := filepath.Split(path)

        // if not follow symbol link, then resolve symbol link of parent dir
        var resolvedDirPath string
        resolvedDirPath, err = filepath.EvalSymlinks(dirPath)
        if err != nil {
            return
        }
        // resolvedDirPath will have been cleaned (no trailing path separators) so
        // we can manually join it with the base path element.
        resolvedPath = resolvedDirPath + string(filepath.Separator) + basePath
        if hasTrailingPathSeparator(path) &&
            filepath.Base(path) != filepath.Base(resolvedPath) {
            rebaseName = filepath.Base(path)
        }
    }
    return resolvedPath, rebaseName, nil
}

// GetRebaseName normalizes and compares path and resolvedPath,
// return completed resolved path and rebased file name
func GetRebaseName(path, resolvedPath string) (string, string) {
    // linkTarget will have been cleaned (no trailing path separators and dot) so
    // we can manually join it with them
    var rebaseName string
    if specifiesCurrentDir(path) &&
        !specifiesCurrentDir(resolvedPath) {
        resolvedPath += string(filepath.Separator) + "."
    }

    if hasTrailingPathSeparator(path) &&
        !hasTrailingPathSeparator(resolvedPath) {
        resolvedPath += string(filepath.Separator)
    }

    if filepath.Base(path) != filepath.Base(resolvedPath) {
        // In the case where the path had a trailing separator and a symlink
        // evaluation has changed the last path component, we will need to
        // rebase the name in the archive that is being copied to match the
        // originally requested name.
        rebaseName = filepath.Base(path)
    }
    return resolvedPath, rebaseName
}