Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 248 additions & 23 deletions client/archive.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"archive/tar"
"bytes"
"context"
"fmt"
Expand All @@ -10,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/alecthomas/errors"
)
Expand All @@ -35,6 +37,9 @@ func Archive(ctx context.Context, w io.Writer, baseDir string, includePaths []st
return errors.Errorf("not a directory: %s", baseDir)
}
for _, path := range includePaths {
if strings.Contains(path, "\\") || !filepath.IsLocal(path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject include paths through symlinked parents

When baseDir/link is a symlink to an outside directory, link/secret is lexically local and passes this check; os.Stat and the Linux GNU tar 1.35 command used below resolve the intermediate symlink, archiving the outside file as link/secret. Since this cache can back onto remote instances, the containment check can still leak files outside baseDir; walk the parent components with Lstat before invoking tar (while allowing a final symlink if desired).

AGENTS.md reference: AGENTS.md:L1-L5

Useful? React with 👍 / 👎.

return errors.Errorf("include path %q must be relative and stay within base directory", path)
}
if _, err := os.Stat(filepath.Join(baseDir, path)); err != nil {
return errors.Wrapf(err, "failed to stat include path %q", path)
}
Expand All @@ -51,13 +56,12 @@ func Archive(ctx context.Context, w io.Writer, baseDir string, includePaths []st
}

// Extract decompresses a zstd+tar stream from r into directory, preserving
// file permissions, ownership, and symlinks. threads controls zstd
// parallelism; 0 uses all CPU cores.
// file permissions and symlinks. threads controls zstd parallelism; 0 uses all
// CPU cores.
//
// Existing read-only directories are made owner-writable so tar can replace
// their contents. tar re-applies archived modes, and a failed extract
// restores the originals; read-only directories absent from the archive are
// left owner-writable.
// Existing read-only directories are made owner-writable so entries can be
// replaced. Archived directory modes are applied after their contents, and a
// failed extract restores pre-existing read-only directories.
func Extract(ctx context.Context, r io.Reader, directory string, threads int) (rerr error) {
if threads <= 0 {
threads = runtime.GOMAXPROCS(0)
Expand All @@ -67,8 +71,6 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r
return errors.Wrap(err, "failed to create target directory")
}

// Relax read-only dirs (e.g. Hermit package trees) so tar can overwrite
// their contents, restoring the original modes if anything fails.
relaxed, err := makeTreeWritable(directory)
defer func() {
if rerr != nil {
Expand All @@ -80,47 +82,270 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r
}

zstdCmd := exec.CommandContext(ctx, "zstd", "-dc", fmt.Sprintf("-T%d", threads)) //nolint:gosec
tarCmd := exec.CommandContext(ctx, "tar", "-xpf", "-", "-C", directory)

pr, pw, err := os.Pipe()
if err != nil {
return errors.Wrap(err, "failed to create pipe")
}

var zstdStderr, tarStderr bytes.Buffer
var zstdStderr bytes.Buffer
zstdCmd.Stdin = r
zstdCmd.Stdout = pw
zstdCmd.Stderr = &zstdStderr

tarCmd.Stdin = pr
tarCmd.Stderr = &tarStderr

if err := zstdCmd.Start(); err != nil {
pw.Close() //nolint:errcheck,gosec
pr.Close() //nolint:errcheck,gosec
return errors.Wrap(err, "failed to start zstd")
}
pw.Close() //nolint:errcheck,gosec

if err := tarCmd.Start(); err != nil {
pr.Close() //nolint:errcheck,gosec
return errors.Join(errors.Wrap(err, "failed to start tar"), zstdCmd.Wait())
}
extractErr := safeExtractTar(ctx, pr, directory)
pr.Close() //nolint:errcheck,gosec

zstdErr := zstdCmd.Wait()
tarErr := tarCmd.Wait()

var errs []error
if extractErr != nil {
errs = append(errs, extractErr)
}
if zstdErr != nil {
errs = append(errs, errors.Errorf("zstd failed: %w: %s", zstdErr, zstdStderr.String()))
}
if tarErr != nil {
errs = append(errs, errors.Errorf("tar failed: %w: %s", tarErr, tarStderr.String()))
}
return errors.Join(errs...)
}

func safeExtractTar(ctx context.Context, r io.Reader, directory string) error {
tr := tar.NewReader(r)
var dirs []dirMode
for {
if err := ctx.Err(); err != nil {
return errors.WithStack(err)
}
hdr, err := tr.Next()
switch {
case errors.Is(err, io.EOF):
return applyDirModes(dirs)
Comment on lines +125 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject EOF before any tar framing

When a cache object is a valid zstd frame whose decompressed payload is empty (or only one truncated zero block), archive/tar.Reader.Next returns io.EOF immediately and this branch reports success, so Client.Restore can report a hit while restoring nothing. The previous Linux GNU tar 1.35 extraction rejects zero-byte input as not a tar archive; track decompressed framing or byte count and distinguish a real tar terminator from EOF before any framing.

Useful? React with 👍 / 👎.

case err != nil:
return errors.Wrap(err, "failed to read tar header")
}

if err := extractTarEntry(directory, hdr, tr, &dirs); err != nil {
return err
}
}
}

func applyDirModes(dirs []dirMode) error {
for i := len(dirs) - 1; i >= 0; i-- {
if err := os.Chmod(dirs[i].path, dirs[i].mode); err != nil {
return errors.Wrapf(err, "failed to chmod %s", dirs[i].path)
}
}
return nil
}

func extractTarEntry(directory string, hdr *tar.Header, r io.Reader, dirs *[]dirMode) error {
target, rel, err := safeArchivePath(directory, hdr.Name)
if err != nil {
return err
}
mode := tarMode(hdr)
switch hdr.Typeflag {
case tar.TypeDir:
if err := mkdirAllWithin(directory, rel, mode|0o700); err != nil {
return err
}
*dirs = append(*dirs, dirMode{path: target, mode: mode})
case tar.TypeReg, 0:
return extractRegularFile(directory, rel, target, mode, r)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore archived modification times

When cached artifacts are consumed by timestamp-sensitive tools such as Make, Ninja, or Git's index, this regular-file branch forwards only mode and contents, so each new file gets extraction time instead of hdr.ModTime. In the Linux GNU tar 1.35 path replaced here, -m/--touch is documented as “don't extract file modified time” and was not passed, so this changes every restored mtime and can trigger rebuilds or incorrect freshness decisions; apply the header mtime after writing.

AGENTS.md reference: AGENTS.md:L1-L2

Useful? React with 👍 / 👎.

case tar.TypeSymlink:
return extractSymlink(directory, rel, target, hdr.Linkname)
case tar.TypeLink:
return extractHardLink(directory, rel, target, hdr.Linkname)
case tar.TypeXHeader, tar.TypeXGlobalHeader:
return nil
default:
return errors.Errorf("unsupported tar entry %q type %d", hdr.Name, hdr.Typeflag)
}
return nil
}

func tarMode(hdr *tar.Header) fs.FileMode {
return fs.FileMode(uint32(hdr.Mode & int64(fs.ModePerm)))
}

func extractRegularFile(directory, rel, target string, mode fs.FileMode, r io.Reader) error {
if err := mkdirAllWithin(directory, filepath.Dir(rel), 0o750); err != nil {
return err
}
if err := ensureNoSymlinkParents(directory, rel); err != nil {
return err
}
if err := rejectSymlink(target); err != nil {
return err
}
if err := removeExistingNonDir(target); err != nil {
return err
}
return writeRegularFile(target, mode, r)
}

func extractSymlink(directory, rel, target, linkname string) error {
if err := validateSymlinkTarget(rel, linkname); err != nil {
return err
}
if err := mkdirAllWithin(directory, filepath.Dir(rel), 0o750); err != nil {
return err
}
if err := ensureNoSymlinkParents(directory, rel); err != nil {
return err
}
if err := removeExistingNonDir(target); err != nil {
return err
}
if err := os.Symlink(linkname, target); err != nil {
return errors.Wrapf(err, "failed to create symlink %s", target)
}
return nil
}

func extractHardLink(directory, rel, target, linkname string) error {
linkTarget, _, err := safeArchivePath(directory, linkname)
if err != nil {
return err
}
if err := rejectSymlink(linkTarget); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject symlinked parents of hard-link sources

When extracting into an existing tree containing pivot -> /outside, an archive hard-link entry whose Linkname is pivot/secret passes safeArchivePath and this check because Lstat follows the parent symlink and sees the outside regular file; os.Link then creates an in-tree hard link to that outside inode. A later build step that writes the restored path can therefore modify a file outside directory, so validate every source component or restrict hard links to previously extracted in-root regular entries.

Useful? React with 👍 / 👎.

return err
}
if err := mkdirAllWithin(directory, filepath.Dir(rel), 0o750); err != nil {
return err
}
if err := ensureNoSymlinkParents(directory, rel); err != nil {
return err
}
if err := removeExistingNonDir(target); err != nil {
return err
}
if err := os.Link(linkTarget, target); err != nil {
return errors.Wrapf(err, "failed to create hard link %s", target)
}
return nil
}

func safeArchivePath(root, name string) (string, string, error) {
if strings.Contains(name, "\\") {
return "", "", errors.Errorf("archive path %q contains a path separator unsupported by tar", name)
}
rel := filepath.FromSlash(name)
if !filepath.IsLocal(rel) {
return "", "", errors.Errorf("archive path %q must be relative and stay within target directory", name)
}
rel = filepath.Clean(rel)
return filepath.Join(root, rel), rel, nil
}

func validateSymlinkTarget(rel, linkname string) error {
if strings.Contains(linkname, "\\") {
return errors.Errorf("symlink target %q contains a path separator unsupported by tar", linkname)
}
linkTarget := filepath.FromSlash(linkname)
if filepath.IsAbs(linkTarget) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), linkTarget)) {
return errors.Errorf("symlink target %q must be relative and stay within target directory", linkname)
}
return nil
}

func mkdirAllWithin(root, rel string, mode fs.FileMode) error {
if rel == "." {
return nil
}
current := root
for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
switch {
case err == nil && info.Mode()&fs.ModeSymlink != 0:
return errors.Errorf("refusing to extract through symlink %s", current)
case err == nil && !info.IsDir():
return errors.Errorf("refusing to create directory over non-directory %s", current)
case err == nil:
continue
case !errors.Is(err, os.ErrNotExist):
return errors.Wrapf(err, "failed to stat %s", current)
}
if err := os.Mkdir(current, mode); err != nil && !errors.Is(err, os.ErrExist) {
return errors.Wrapf(err, "failed to create directory %s", current)
}
}
return nil
}

func ensureNoSymlinkParents(root, rel string) error {
dir := filepath.Dir(rel)
if dir == "." {
return nil
}
current := root
for part := range strings.SplitSeq(dir, string(filepath.Separator)) {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil {
return errors.Wrapf(err, "failed to stat %s", current)
}
if info.Mode()&fs.ModeSymlink != 0 {
return errors.Errorf("refusing to extract through symlink %s", current)
}
}
return nil
}

func rejectSymlink(path string) error {
info, err := os.Lstat(path) //nolint:gosec
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return errors.Wrapf(err, "failed to stat %s", path)
}
if info.Mode()&fs.ModeSymlink != 0 {
return errors.Errorf("refusing to replace symlink %s", path)
}
return nil
}

func removeExistingNonDir(path string) error {
info, err := os.Lstat(path) //nolint:gosec
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return errors.Wrapf(err, "failed to stat %s", path)
}
if info.IsDir() {
return errors.Errorf("refusing to replace directory %s", path)
}
if err := os.Remove(path); err != nil { //nolint:gosec
return errors.Wrapf(err, "failed to remove %s", path)
}
return nil
}

func writeRegularFile(path string, mode fs.FileMode, r io.Reader) (rerr error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) //nolint:gosec
if err != nil {
return errors.Wrapf(err, "failed to create file %s", path)
}
defer func() {
rerr = errors.Join(rerr, f.Close())
}()
if _, err := io.Copy(f, r); err != nil {
return errors.Wrapf(err, "failed to write file %s", path)
}
if err := f.Chmod(mode); err != nil {
return errors.Wrapf(err, "failed to chmod %s", path)
}
return nil
}

// dirMode is a directory path and its pre-relaxation mode.
type dirMode struct {
path string
Expand Down
Loading
Loading