From f0bf252c2d46c1e4436fbd980574f2714d4d97c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:34:18 +0000 Subject: [PATCH 1/3] Upgrade moby/go-archive to v0.3.3 and refresh vendor Co-authored-by: Zefty <25576710+Zefty@users.noreply.github.com> --- go.mod | 2 +- go.sum | 2 + vendor/github.com/moby/go-archive/archive.go | 261 ++++++++++++------ .../moby/go-archive/archive_linux.go | 18 ++ .../moby/go-archive/archive_other.go | 8 + .../moby/go-archive/archive_unix.go | 26 +- .../moby/go-archive/archive_windows.go | 2 +- .../github.com/moby/go-archive/chmod_linux.go | 46 +++ .../moby/go-archive/chmod_unix_nolinux.go | 27 ++ vendor/github.com/moby/go-archive/diff.go | 36 +-- .../internal/archiveoptions/options.go | 12 + vendor/github.com/moby/go-archive/rootpath.go | 60 +++- vendor/modules.txt | 3 +- 13 files changed, 370 insertions(+), 133 deletions(-) create mode 100644 vendor/github.com/moby/go-archive/chmod_linux.go create mode 100644 vendor/github.com/moby/go-archive/chmod_unix_nolinux.go create mode 100644 vendor/github.com/moby/go-archive/internal/archiveoptions/options.go diff --git a/go.mod b/go.mod index ce02b016..14dfa198 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/docker/distribution v2.8.2+incompatible github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/moby/go-archive v0.3.0 + github.com/moby/go-archive v0.3.3 github.com/moby/sys/symlink v0.2.0 github.com/pkg/errors v0.9.1 github.com/urfave/cli v1.22.12 diff --git a/go.sum b/go.sum index 6564155c..96741c73 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c= github.com/moby/go-archive v0.3.0/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= +github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= +github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= diff --git a/vendor/github.com/moby/go-archive/archive.go b/vendor/github.com/moby/go-archive/archive.go index 4d9e87d2..689720af 100644 --- a/vendor/github.com/moby/go-archive/archive.go +++ b/vendor/github.com/moby/go-archive/archive.go @@ -17,6 +17,7 @@ import ( "time" "github.com/containerd/log" + "github.com/moby/go-archive/internal/archiveoptions" "github.com/moby/patternmatcher" "github.com/moby/sys/sequential" "github.com/moby/sys/user" @@ -81,9 +82,22 @@ type ( // were probably in the archive for a reason, so set this option at // your own peril. BestEffortXattrs bool + + // internalOptions contains options for use by packages within this module. + internalOptions *archiveoptions.Options } ) +// WithProcSelfFD returns a copy of opts prepared for extraction in a +// filesystem context where /proc/self/fd may not be accessible by path. +// +// The caller must invoke the returned cleanup function after extraction +// completes. On platforms that do not use /proc/self/fd for extraction, +// the returned cleanup function is a no-op. +func WithProcSelfFD(opts *TarOptions) (*TarOptions, func(), error) { + return withProcSelfFD(opts) +} + // Archiver implements the Archiver interface and allows the reuse of most utility functions of // this package with a pluggable Untar function. Also, to facilitate the passing of specific id // mappings for untar, an Archiver can be created with maps which will then be passed to Untar operations. @@ -439,6 +453,90 @@ func (ta *tarAppender) addTarFile(srcPath, archivePath string) error { return nil } +// resolveArchivePath resolves intermediate symlinks in name using chroot-like +// semantics when os.Root cannot traverse them. The final path component is +// intentionally preserved because archive extraction may create or replace it. +// +// This is a compatibility workaround rather than the preferred long-term +// implementation. It resolves the path separately before the actual operation, +// so a concurrent filesystem change may cause the operation to affect a +// different path within root. The subsequent os.Root operation still confines +// the operation to root and prevents such a change from escaping it. +// +// Paths with missing components are supported. Existing symlinks are resolved, +// and any remaining nonexistent components are retained for later creation. +// +// This helper should eventually be replaced by handle-relative resolution and +// operations with resolve-in-root semantics, avoiding the resolution/use race +// and repeated path traversal. +func resolveArchivePath(root *os.Root, name string) (string, error) { + parent, base := filepath.Split(name) + if parent == "" { + return name, nil + } + + parent = filepath.Clean(parent) + + // Follow the final parent component: it is an intermediate component of name, + // and an absolute symlink there must trigger the resolve-in-root fallback. + _, statErr := root.Stat(parent) + switch { + case statErr == nil: + return name, nil + case !os.IsNotExist(statErr) && !isPathEscapes(statErr): + return "", statErr + } + + // Resolve the parent both to handle ENOENT from missing components or dangling + // symlinks, and to determine whether an os.Root breakout was caused by an + // absolute symlink. Relative symlink escapes preserve the original Stat error. + resolved, err := resolveFSRootPath(root.Name(), parent) + if err != nil { + return "", err + } + + if isPathEscapes(statErr) && (!resolved.followedAbsoluteLink || resolved.relativeEscapeBeforeAbsolute) { + return "", statErr + } + + relParent, err := filepath.Rel(root.Name(), resolved.path) + if err != nil { + return "", breakoutError(fmt.Errorf( + "could not make resolved parent %q relative to root %q: %w", + resolved.path, + root.Name(), + err, + )) + } + if relParent != "." && !filepath.IsLocal(relParent) { + return "", breakoutError(fmt.Errorf( + "resolved parent %q escapes root %q", + resolved.path, + root.Name(), + )) + } + + return filepath.Join(relParent, base), nil +} + +// resolveHardlinkTarget validates a POSIX hardlink target and resolves it to +// the native, root-relative filesystem path used for extraction. +func resolveHardlinkTarget(root *os.Root, linkname string) (string, error) { + cleaned := path.Clean(linkname) + if strings.HasPrefix(cleaned, "/") { + // Some image builders (e.g. kaniko) write hardlink targets as absolute + // paths. Resolve those relative to the extraction root, with chroot-like + // semantics matching absolute symlink targets. Strip the root from the + // original linkname rather than the cleaned one so that ".." components + // are not collapsed against "/" but instead rejected below. + cleaned = path.Clean(strings.TrimLeft(linkname, "/")) + } + if cleaned == "." || !filepath.IsLocal(cleaned) { + return "", breakoutError(fmt.Errorf("invalid hardlink target %q", linkname)) + } + return resolveArchivePath(root, filepath.FromSlash(cleaned)) +} + // createTarFile extracts a single tar entry into the given root. dstPath is the // root-relative path of the entry being extracted, in native (host-separator) // form so it can be passed directly to os.Root methods and fsRootPath. @@ -447,6 +545,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea Lchown = true inUserns, bestEffortXattrs bool chownOpts *ChownOpts + internalOpts *archiveoptions.Options ) // TODO(thaJeztah): make opts a required argument. @@ -455,6 +554,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea inUserns = opts.InUserNS // TODO(thaJeztah): consider deprecating opts.InUserNS and detect locally. chownOpts = opts.ChownOpts bestEffortXattrs = opts.BestEffortXattrs + internalOpts = opts.internalOptions } // hdr.Mode is in linux format, which we can use for sycalls, @@ -462,6 +562,15 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea // so use hdrInfo.Mode() (they differ for e.g. setuid bits) hdrInfo := hdr.FileInfo() + var hardlinkTarget string + if hdr.Typeflag == tar.TypeLink { + var err error + hardlinkTarget, err = resolveHardlinkTarget(root, hdr.Linkname) + if err != nil { + return err + } + } + switch hdr.Typeflag { case tar.TypeDir: // Create directory unless it already exists as one; merge in that case. @@ -511,13 +620,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea } case tar.TypeLink: - // Defence in depth: root.Link's containment is limited when - // dest is a volume root. - linkname := path.Clean(hdr.Linkname) - if linkname == "." || !filepath.IsLocal(linkname) { - return breakoutError(fmt.Errorf("invalid hardlink target %q", hdr.Linkname)) - } - if err := root.Link(filepath.FromSlash(linkname), dstPath); err != nil { + if err := root.Link(hardlinkTarget, dstPath); err != nil { return err } @@ -593,7 +696,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea // There is no LChmod, so ignore mode for symlink. Also, this // must happen after chown, as that can modify the file mode - if err := handleLChmod(root, dstPath, hdr, hdrInfo); err != nil { + if err := handleLChmod(root, dstPath, hardlinkTarget, hdr, hdrInfo, internalOpts); err != nil { return err } @@ -608,7 +711,7 @@ func createTarFile(root *os.Root, dstPath string, hdr *tar.Header, reader io.Rea } case tar.TypeLink: // Follow the hardlink only when its target is not itself a symlink. - fi, err := root.Lstat(filepath.FromSlash(path.Clean(hdr.Linkname))) + fi, err := root.Lstat(hardlinkTarget) if err == nil && fi.Mode()&os.ModeSymlink == 0 { if err := chtimes(root, dstPath, aTime, mTime); err != nil { return err @@ -698,13 +801,13 @@ func (t *Tarballer) Do() { defer func() { // Make sure to check the error on Close. - if err := ta.TarWriter.Close(); err != nil { + if err := ta.TarWriter.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) { log.G(context.TODO()).Errorf("Can't close tar writer: %s", err) } - if err := t.compressWriter.Close(); err != nil { + if err := t.compressWriter.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) { log.G(context.TODO()).Errorf("Can't close compress writer: %s", err) } - if err := t.pipeWriter.Close(); err != nil { + if err := t.pipeWriter.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) { log.G(context.TODO()).Errorf("Can't close pipe writer: %s", err) } }() @@ -931,7 +1034,10 @@ loop: // dstPath is the native (host-separator) form of the entry name, // used at all filesystem boundaries (os.Root methods, fsRootPath). // hdr.Name stays POSIX (forward-slash) for logical string checks. - dstPath := filepath.FromSlash(hdr.Name) + dstPath, err := resolveArchivePath(root, filepath.FromSlash(hdr.Name)) + if err != nil { + return err + } // If dstPath exists we almost always just want to remove and replace it. // The only exception is when it is a directory *and* the file from @@ -969,7 +1075,7 @@ loop: // // This must be done before whiteoutConverter.ConvertRead, which // may set xattrs on the directory or create whiteout files. - if err := createImpliedDirectories(root, hdr, options); err != nil { + if err := createImpliedDirectories(root, dstPath, options); err != nil { return err } @@ -1024,81 +1130,80 @@ func unrepresentableOnWindows(hdr *tar.Header) error { return nil } -// createImpliedDirectories will create all parent directories of the current path with default permissions, if they do -// not already exist. This is possible as the tar format supports 'implicit' directories, where their existence is -// defined by the paths of files in the tar, but there are no header entries for the directories themselves, and thus -// we most both create them and choose metadata like permissions. +// createImpliedDirectories creates all parent directories of dstPath with +// default permissions if they do not already exist. This is necessary because +// the tar format permits implicit directories whose existence is defined only +// by file paths, without corresponding directory headers from which metadata +// could be restored. // -// The caller must have normalized hdr.Name (no leading ".." components). -// All directory creation is performed via root so it is bounded within the -// destination at the OS level (openat(2) semantics), preventing escape via -// symlinks in the destination tree. -func createImpliedDirectories(root *os.Root, hdr *tar.Header, options *TarOptions) error { - // For non-directory entries, ensure that the parent directory exists. - if hdr.Typeflag != tar.TypeDir { - parent := filepath.FromSlash(path.Dir(strings.TrimSuffix(hdr.Name, "/"))) - // Skip when the parent is the root itself; nothing to create. - if parent == "." || parent == "" { - return nil - } - if _, err := root.Lstat(parent); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err +// The caller must pass a normalized, root-relative local path. Any archive-path +// conversion and resolve-in-root handling must already have been applied. +// Directory creation is performed through root, so it remains confined to the +// extraction destination even if the destination tree changes concurrently. +func createImpliedDirectories(root *os.Root, dstPath string, options *TarOptions) error { + parent := filepath.Dir(dstPath) + + // Skip when the parent is the root itself; nothing to create. + if parent == "." || parent == "" { + return nil + } + if _, err := root.Lstat(parent); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + // RootPair() is confined inside this loop as most cases will not require a call, so we can spend some + // unneeded function calls in the uncommon case to encapsulate logic -- implied directories are a niche + // usage that reduces the portability of an image. + uid, gid := options.IDMap.RootPair() + + // Similar to [user.MkdirAllAndChown] + // + // [user.MkdirAllAndChown]: https://pkg.go.dev/github.com/moby/sys/user#MkdirAllAndChown + var cur string + for c := range strings.SplitSeq(parent, string(os.PathSeparator)) { + if c == "" { + continue } - // RootPair() is confined inside this loop as most cases will not require a call, so we can spend some - // unneeded function calls in the uncommon case to encapsulate logic -- implied directories are a niche - // usage that reduces the portability of an image. - uid, gid := options.IDMap.RootPair() - - // Similar to [user.MkdirAllAndChown] - // - // [user.MkdirAllAndChown]: https://pkg.go.dev/github.com/moby/sys/user#MkdirAllAndChown - var cur string - for c := range strings.SplitSeq(parent, string(os.PathSeparator)) { - if c == "" { - continue + cur = filepath.Join(cur, c) + if err := root.Mkdir(cur, ImpliedDirectoryMode); err != nil { + if !errors.Is(err, os.ErrExist) { + return err } - cur = filepath.Join(cur, c) - if err := root.Mkdir(cur, ImpliedDirectoryMode); err != nil { - if !errors.Is(err, os.ErrExist) { - return err - } - fi, err := root.Stat(cur) - if err != nil { - return err - } - if fi.IsDir() { - continue - } - return &os.PathError{Op: "mkdir", Path: cur, Err: syscall.ENOTDIR} - } - if options.NoLchown { - continue - } - // Only the successful Mkdir case is newly-created. - dir, err := root.Open(cur) + fi, err := root.Stat(cur) if err != nil { return err } - if uid != 0 || gid != 0 { - if err := dir.Chown(uid, gid); err != nil { - _ = dir.Close() - return err - } + if fi.IsDir() { + continue } - // root.Mkdir applies the mode subject to the process umask, so - // re-apply it with Chmod to guarantee ImpliedDirectoryMode - // independent of umask, matching the previous MkdirAllAndChown - // behavior. - if err := dir.Chmod(ImpliedDirectoryMode); err != nil { + return &os.PathError{Op: "mkdir", Path: cur, Err: syscall.ENOTDIR} + } + if options.NoLchown { + continue + } + // Only the successful Mkdir case is newly-created. + dir, err := root.Open(cur) + if err != nil { + return err + } + if uid != 0 || gid != 0 { + if err := dir.Chown(uid, gid); err != nil { _ = dir.Close() return err } - if err := dir.Close(); err != nil { - return err - } + } + // root.Mkdir applies the mode subject to the process umask, so + // re-apply it with Chmod to guarantee ImpliedDirectoryMode + // independent of umask, matching the previous MkdirAllAndChown + // behavior. + if err := dir.Chmod(ImpliedDirectoryMode); err != nil { + _ = dir.Close() + return err + } + if err := dir.Close(); err != nil { + return err } } diff --git a/vendor/github.com/moby/go-archive/archive_linux.go b/vendor/github.com/moby/go-archive/archive_linux.go index 9341bc59..c813cf9a 100644 --- a/vendor/github.com/moby/go-archive/archive_linux.go +++ b/vendor/github.com/moby/go-archive/archive_linux.go @@ -8,10 +8,28 @@ import ( "path/filepath" "strings" + "github.com/moby/go-archive/internal/archiveoptions" "github.com/moby/sys/userns" "golang.org/x/sys/unix" ) +func withProcSelfFD(opts *TarOptions) (*TarOptions, func(), error) { + procSelfFD, err := os.Open("/proc/self/fd") + if err != nil { + return nil, nil, err + } + + var prepared TarOptions + if opts != nil { + prepared = *opts + } + prepared.internalOptions = &archiveoptions.Options{ + ProcSelfFD: procSelfFD, + } + + return &prepared, func() { _ = procSelfFD.Close() }, nil +} + func getWhiteoutConverter(format WhiteoutFormat) tarWhiteoutConverter { if format == OverlayWhiteoutFormat { return newOverlayWhiteoutConverter() diff --git a/vendor/github.com/moby/go-archive/archive_other.go b/vendor/github.com/moby/go-archive/archive_other.go index 6495549f..c8fe0434 100644 --- a/vendor/github.com/moby/go-archive/archive_other.go +++ b/vendor/github.com/moby/go-archive/archive_other.go @@ -2,6 +2,14 @@ package archive +func withProcSelfFD(opts *TarOptions) (*TarOptions, func(), error) { + var prepared TarOptions + if opts != nil { + prepared = *opts + } + return &prepared, func() {}, nil +} + func getWhiteoutConverter(format WhiteoutFormat) tarWhiteoutConverter { return nil } diff --git a/vendor/github.com/moby/go-archive/archive_unix.go b/vendor/github.com/moby/go-archive/archive_unix.go index e2dcc429..6b333e5b 100644 --- a/vendor/github.com/moby/go-archive/archive_unix.go +++ b/vendor/github.com/moby/go-archive/archive_unix.go @@ -8,11 +8,11 @@ import ( "fmt" "math" "os" - "path" "path/filepath" "strings" "syscall" + "github.com/moby/go-archive/internal/archiveoptions" "golang.org/x/sys/unix" ) @@ -88,7 +88,7 @@ func handleTarTypeBlockCharFifo(root *os.Root, hdr *tar.Header, dstPath string) // handleLChmod applies the mode from hdrInfo to dstPath within root, skipping // symlinks (there is no lchmod). For hardlinks, the mode is applied only when // the link target is itself not a symlink. -func handleLChmod(root *os.Root, dstPath string, hdr *tar.Header, hdrInfo os.FileInfo) error { +func handleLChmod(root *os.Root, dstPath string, hardlinkTarget string, hdr *tar.Header, hdrInfo os.FileInfo, opts *archiveoptions.Options) error { switch hdr.Typeflag { case tar.TypeSymlink: return nil @@ -96,21 +96,21 @@ func handleLChmod(root *os.Root, dstPath string, hdr *tar.Header, hdrInfo os.Fil case tar.TypeLink: // If the target is a symlink, there is no way to chmod the hardlink // without following it. - fi, err := root.Lstat(filepath.FromSlash(path.Clean(hdr.Linkname))) + fi, err := root.Lstat(hardlinkTarget) if err != nil || fi.Mode()&os.ModeSymlink != 0 { return nil } - return chmodNoSymlink(root, dstPath, hdrInfo.Mode()) + return chmodNoSymlink(root, dstPath, hdrInfo.Mode(), opts) default: - return chmodNoSymlink(root, dstPath, hdrInfo.Mode()) + return chmodNoSymlink(root, dstPath, hdrInfo.Mode(), opts) } } // chmodNoSymlink applies mode to a non-symlink entry. // // Callers must have already excluded symlink entries. -func chmodNoSymlink(root *os.Root, name string, mode os.FileMode) error { +func chmodNoSymlink(root *os.Root, name string, mode os.FileMode, opts *archiveoptions.Options) error { parent, err := root.OpenFile(filepath.Dir(name), os.O_RDONLY, 0) if err != nil { return err @@ -127,19 +127,7 @@ func chmodNoSymlink(root *os.Root, name string, mode os.FileMode) error { } // Fallback for systems that cannot perform fchmodat with AT_SYMLINK_NOFOLLOW. - // Open the entry without following symlinks and apply the mode through the - // resulting file descriptor. - // #nosec G115 -- ignore integer overflow conversion for parent.Fd - fd, err := unix.Openat(int(parent.Fd()), base, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) - if err != nil { - return &os.PathError{Op: "openat", Path: name, Err: err} - } - defer unix.Close(fd) - - if err := unix.Fchmod(fd, perm); err != nil { - return &os.PathError{Op: "fchmod", Path: name, Err: err} - } - return nil + return chmodNoSymlinkFallback(int(parent.Fd()), base, name, perm, opts) // #nosec G115 -- ignore integer overflow conversion for parent.Fd } // fileModeToPerm returns the subset of an os.FileMode that can be applied diff --git a/vendor/github.com/moby/go-archive/archive_windows.go b/vendor/github.com/moby/go-archive/archive_windows.go index ffe00a44..a0e43363 100644 --- a/vendor/github.com/moby/go-archive/archive_windows.go +++ b/vendor/github.com/moby/go-archive/archive_windows.go @@ -53,7 +53,7 @@ func handleTarTypeBlockCharFifo(root *os.Root, hdr *tar.Header, path string) err } // handleLChmod is a no-op on Windows because chmod is not supported. -func handleLChmod(root *os.Root, path string, hdr *tar.Header, hdrInfo os.FileInfo) error { +func handleLChmod(root *os.Root, dstPath string, hardlinkTarget string, hdr *tar.Header, hdrInfo os.FileInfo, opts any) error { return nil } diff --git a/vendor/github.com/moby/go-archive/chmod_linux.go b/vendor/github.com/moby/go-archive/chmod_linux.go new file mode 100644 index 00000000..8d92f594 --- /dev/null +++ b/vendor/github.com/moby/go-archive/chmod_linux.go @@ -0,0 +1,46 @@ +package archive + +import ( + "fmt" + "os" + "runtime" + "strconv" + + "github.com/moby/go-archive/internal/archiveoptions" + "golang.org/x/sys/unix" +) + +// chmodNoSymlinkFallback applies mode without following the final path +// component on systems without fchmodat2 support. +// +// Callers must have already excluded symlink entries. +func chmodNoSymlinkFallback(parentFD int, base, name string, perm uint32, opts *archiveoptions.Options) error { + fd, err := unix.Openat(parentFD, base, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return &os.PathError{Op: "openat", Path: name, Err: err} + } + defer unix.Close(fd) + + if opts != nil && opts.ProcSelfFD != nil { + err := unix.Fchmodat(int(opts.ProcSelfFD.Fd()), strconv.Itoa(fd), perm, 0) + // Keep the os.File alive until fchmodat has finished using its descriptor. + runtime.KeepAlive(opts.ProcSelfFD) + if err != nil { + return &os.PathError{ + Op: "fchmodat", + Path: name, + Err: fmt.Errorf("via pre-opened /proc/self/fd/%d: %w", fd, err), + } + } + } else { + procPath := "/proc/self/fd/" + strconv.Itoa(fd) + if err := unix.Chmod(procPath, perm); err != nil { + return &os.PathError{ + Op: "chmod", + Path: name, + Err: fmt.Errorf("via %s: %w", procPath, err), + } + } + } + return nil +} diff --git a/vendor/github.com/moby/go-archive/chmod_unix_nolinux.go b/vendor/github.com/moby/go-archive/chmod_unix_nolinux.go new file mode 100644 index 00000000..0acddb1b --- /dev/null +++ b/vendor/github.com/moby/go-archive/chmod_unix_nolinux.go @@ -0,0 +1,27 @@ +//go:build !linux && !windows + +package archive + +import ( + "os" + + "github.com/moby/go-archive/internal/archiveoptions" + "golang.org/x/sys/unix" +) + +// chmodNoSymlinkFallback applies mode without following the final path +// component on systems without fchmodat2 support. +// +// Callers must have already excluded symlink entries. +func chmodNoSymlinkFallback(parentFD int, base, name string, perm uint32, _ *archiveoptions.Options) error { + fd, err := unix.Openat(parentFD, base, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + return &os.PathError{Op: "openat", Path: name, Err: err} + } + defer unix.Close(fd) + + if err := unix.Fchmod(fd, perm); err != nil { + return &os.PathError{Op: "fchmod", Path: name, Err: err} + } + return nil +} diff --git a/vendor/github.com/moby/go-archive/diff.go b/vendor/github.com/moby/go-archive/diff.go index 055f3c11..b9450186 100644 --- a/vendor/github.com/moby/go-archive/diff.go +++ b/vendor/github.com/moby/go-archive/diff.go @@ -29,8 +29,9 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, tr := tar.NewReader(layer) var dirs []unpackedDir - // unpackedPaths tracks root-relative paths already written in this layer - // so that the AUFS opaque-whiteout walk knows which paths to preserve. + // unpackedPaths tracks resolved, native-separator, root-relative paths + // already written in this layer so that the AUFS opaque-whiteout walk + // knows which paths to preserve. unpackedPaths := make(map[string]struct{}) if options == nil { @@ -71,12 +72,6 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, continue } - // Ensure that the parent directory exists. - err = createImpliedDirectories(root, hdr, options) - if err != nil { - return 0, err - } - // Skip AUFS metadata dirs if strings.HasPrefix(hdr.Name, WhiteoutMetaPrefix) { // Regular files inside /.wh..wh.plnk can be used as hardlink targets @@ -109,10 +104,15 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, // dstPath is the native (host-separator) form of the entry name, // used at all filesystem boundaries (os.Root methods, fsRootPath). // The tar-header name (hdr.Name) is POSIX, so convert it here. - dstPath := filepath.FromSlash(hdr.Name) - base := filepath.Base(dstPath) - - if strings.HasPrefix(base, WhiteoutPrefix) { + dstPath, err := resolveArchivePath(root, filepath.FromSlash(hdr.Name)) + if err != nil { + return 0, err + } + // Ensure that the parent directory exists. + if err := createImpliedDirectories(root, dstPath, options); err != nil { + return 0, err + } + if base := filepath.Base(dstPath); strings.HasPrefix(base, WhiteoutPrefix) { dir := filepath.Dir(dstPath) if base == WhiteoutOpaqueDir { _, err := root.Lstat(dir) @@ -144,9 +144,9 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, return err } - // unpackedPaths is keyed by root-relative slash paths; convert - // filepath.WalkDir's native path before looking it up. - if _, exists := unpackedPaths[filepath.ToSlash(rel)]; !exists { + // unpackedPaths is keyed by resolved, native-separator, + // root-relative paths, matching filepath.WalkDir's paths. + if _, exists := unpackedPaths[rel]; !exists { return root.RemoveAll(rel) } return nil @@ -206,9 +206,9 @@ func UnpackLayer(dest string, layer io.Reader, options *TarOptions) (size int64, if hdr.Typeflag == tar.TypeDir { dirs = append(dirs, unpackedDir{hdr: hdr, name: dstPath}) } - // unpackedPaths is keyed by the POSIX (forward-slash) name so it - // matches the ToSlash'd lookup in the opaque-whiteout walk above. - unpackedPaths[hdr.Name] = struct{}{} + // Record the resolved, native-separator, root-relative path so it + // matches the paths produced by the opaque-whiteout walk. + unpackedPaths[dstPath] = struct{}{} } } diff --git a/vendor/github.com/moby/go-archive/internal/archiveoptions/options.go b/vendor/github.com/moby/go-archive/internal/archiveoptions/options.go new file mode 100644 index 00000000..201eb91b --- /dev/null +++ b/vendor/github.com/moby/go-archive/internal/archiveoptions/options.go @@ -0,0 +1,12 @@ +// Package archiveoptions defines internal options shared between archive and +// chrootarchive. +package archiveoptions + +import "os" + +// Options contains extraction resources supplied by internal callers. +type Options struct { + // ProcSelfFD references /proc/self/fd as opened before entering a chroot. + // The caller retains ownership of the file. + ProcSelfFD *os.File +} diff --git a/vendor/github.com/moby/go-archive/rootpath.go b/vendor/github.com/moby/go-archive/rootpath.go index 3834af2c..7d34839d 100644 --- a/vendor/github.com/moby/go-archive/rootpath.go +++ b/vendor/github.com/moby/go-archive/rootpath.go @@ -24,31 +24,47 @@ import ( var errTooManyLinks = errors.New("too many links") +type fsRootPathResult struct { + path string + followedAbsoluteLink bool + relativeEscapeBeforeAbsolute bool +} + // fsRootPath joins a path with a root, evaluating and bounding any // symlink to the root directory. func fsRootPath(root, path string) (string, error) { + result, err := resolveFSRootPath(root, path) + if err != nil { + return "", err + } + return result.path, nil +} + +func resolveFSRootPath(root, path string) (fsRootPathResult, error) { + result := fsRootPathResult{path: root} if path == "" { - return root, nil + return result, nil } var linksWalked int // to protect against cycles for { i := linksWalked - newpath, err := walkLinks(root, path, &linksWalked) + newpath, err := walkLinks(root, path, &linksWalked, &result) if err != nil { - return "", err + return fsRootPathResult{}, err } path = newpath if i == linksWalked { newpath = filepath.Join(string(os.PathSeparator), newpath) if path == newpath { - return filepath.Join(root, newpath), nil + result.path = filepath.Join(root, newpath) + return result, nil } path = newpath } } } -func walkLink(root, path string, linksWalked *int) (newpath string, islink bool, err error) { +func walkLink(root, path string, linksWalked *int, result *fsRootPathResult) (newpath string, islink bool, err error) { if *linksWalked > 255 { return "", false, errTooManyLinks } @@ -74,37 +90,51 @@ func walkLink(root, path string, linksWalked *int) (newpath string, islink bool, if err != nil { return "", false, err } + if filepath.IsAbs(newpath) { + result.followedAbsoluteLink = true + } else if !result.followedAbsoluteLink { + // Record an escape before a later absolute link can make the original + // os.Root error appear eligible for resolve-in-root fallback. + relativeDir, err := filepath.Rel(string(os.PathSeparator), filepath.Dir(path)) + if err != nil { + return "", false, err + } + + resolved := filepath.Join(relativeDir, newpath) + if resolved != "." && !filepath.IsLocal(resolved) { + result.relativeEscapeBeforeAbsolute = true + } + } + *linksWalked++ return newpath, true, nil } -func walkLinks(root, path string, linksWalked *int) (string, error) { +func walkLinks(root, path string, linksWalked *int, result *fsRootPathResult) (string, error) { switch dir, file := filepath.Split(path); { case dir == "": - newpath, _, err := walkLink(root, file, linksWalked) + newpath, _, err := walkLink(root, file, linksWalked, result) return newpath, err case file == "": if os.IsPathSeparator(dir[len(dir)-1]) { if dir == string(os.PathSeparator) { return dir, nil } - return walkLinks(root, dir[:len(dir)-1], linksWalked) + return walkLinks(root, dir[:len(dir)-1], linksWalked, result) } - newpath, _, err := walkLink(root, dir, linksWalked) + newpath, _, err := walkLink(root, dir, linksWalked, result) return newpath, err + default: - newdir, err := walkLinks(root, dir, linksWalked) + newdir, err := walkLinks(root, dir, linksWalked, result) if err != nil { return "", err } - newpath, islink, err := walkLink(root, filepath.Join(newdir, file), linksWalked) + newpath, islink, err := walkLink(root, filepath.Join(newdir, file), linksWalked, result) if err != nil { return "", err } - if !islink { - return newpath, nil - } - if filepath.IsAbs(newpath) { + if !islink || filepath.IsAbs(newpath) { return newpath, nil } return filepath.Join(newdir, newpath), nil diff --git a/vendor/modules.txt b/vendor/modules.txt index 53a9b610..ca7795ec 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -133,10 +133,11 @@ github.com/mitchellh/go-homedir # github.com/mitchellh/reflectwalk v1.0.2 ## explicit github.com/mitchellh/reflectwalk -# github.com/moby/go-archive v0.3.0 +# github.com/moby/go-archive v0.3.3 ## explicit; go 1.25 github.com/moby/go-archive github.com/moby/go-archive/compression +github.com/moby/go-archive/internal/archiveoptions github.com/moby/go-archive/tarheader # github.com/moby/locker v1.0.1 ## explicit; go 1.13 From 73239706577ea257bed25c8adb264fc2b4c3a580 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:50:16 +0000 Subject: [PATCH 2/3] Test context extraction with omitted parent directory Co-authored-by: Zefty <25576710+Zefty@users.noreply.github.com> --- scan/context_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 scan/context_test.go diff --git a/scan/context_test.go b/scan/context_test.go new file mode 100644 index 00000000..3d1d3424 --- /dev/null +++ b/scan/context_test.go @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scan + +import ( + "archive/tar" + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestGetContextFromReaderCreatesImpliedParentForDirectory(t *testing.T) { + var archive bytes.Buffer + tw := tar.NewWriter(&archive) + + // The archive has no entry for build/ before the build/server/ directory. + if err := tw.WriteHeader(&tar.Header{Name: "build/server/", Typeflag: tar.TypeDir, Mode: 0755, Uid: os.Getuid(), Gid: os.Getgid()}); err != nil { + t.Fatal(err) + } + const contents = "FROM scratch\n" + if err := tw.WriteHeader(&tar.Header{Name: "build/server/Dockerfile", Typeflag: tar.TypeReg, Mode: 0644, Size: int64(len(contents)), Uid: os.Getuid(), Gid: os.Getgid()}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(contents)); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + destination := t.TempDir() + scanner := &Scanner{destinationFolder: destination} + if err := scanner.getContextFromReader(&archive); err != nil { + t.Fatalf("extract context with an implied parent directory: %v", err) + } + + got, err := os.ReadFile(filepath.Join(destination, "build", "server", "Dockerfile")) + if err != nil { + t.Fatal(err) + } + if string(got) != contents { + t.Fatalf("Dockerfile contents = %q, want %q", got, contents) + } +} From bb0727fcda470898577b3c32084757b3a7e732f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:00:37 +0000 Subject: [PATCH 3/3] Cover archive extraction regressions through v0.3.3 Co-authored-by: Zefty <25576710+Zefty@users.noreply.github.com> --- scan/context_linux_test.go | 43 ++++++++++++++ scan/context_test.go | 117 +++++++++++++++++++++++++++++++------ 2 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 scan/context_linux_test.go diff --git a/scan/context_linux_test.go b/scan/context_linux_test.go new file mode 100644 index 00000000..a8fcf842 --- /dev/null +++ b/scan/context_linux_test.go @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scan + +import ( + "archive/tar" + "bytes" + "errors" + "os" + "path/filepath" + "syscall" + "testing" + + "golang.org/x/sys/unix" +) + +func TestGetContextFromReaderUntarsDeviceNode(t *testing.T) { + // Creating device nodes requires CAP_MKNOD, which ordinary test runners lack. + probe := filepath.Join(t.TempDir(), "ptmx") + if err := unix.Mknod(probe, unix.S_IFCHR|0600, int(unix.Mkdev(5, 2))); err != nil { + if errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES) { + t.Skipf("device creation is not permitted: %v", err) + } + t.Fatal(err) + } + + archive := createContextTar(t, []contextTarEntry{ + {header: tar.Header{Name: "dev/ptmx", Typeflag: tar.TypeChar, Mode: 0640, Devmajor: 5, Devminor: 2}}, + }) + destination := t.TempDir() + scanner := &Scanner{destinationFolder: destination} + if err := scanner.getContextFromReader(bytes.NewReader(archive)); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(filepath.Join(destination, "dev", "ptmx")) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeCharDevice == 0 || info.Mode().Perm() != 0640 { + t.Fatalf("extracted device mode = %v, want character device with mode 0640", info.Mode()) + } +} diff --git a/scan/context_test.go b/scan/context_test.go index 3d1d3424..b6c66d41 100644 --- a/scan/context_test.go +++ b/scan/context_test.go @@ -8,39 +8,120 @@ import ( "bytes" "os" "path/filepath" + "runtime" "testing" ) func TestGetContextFromReaderCreatesImpliedParentForDirectory(t *testing.T) { - var archive bytes.Buffer - tw := tar.NewWriter(&archive) - - // The archive has no entry for build/ before the build/server/ directory. - if err := tw.WriteHeader(&tar.Header{Name: "build/server/", Typeflag: tar.TypeDir, Mode: 0755, Uid: os.Getuid(), Gid: os.Getgid()}); err != nil { - t.Fatal(err) - } const contents = "FROM scratch\n" - if err := tw.WriteHeader(&tar.Header{Name: "build/server/Dockerfile", Typeflag: tar.TypeReg, Mode: 0644, Size: int64(len(contents)), Uid: os.Getuid(), Gid: os.Getgid()}); err != nil { - t.Fatal(err) - } - if _, err := tw.Write([]byte(contents)); err != nil { - t.Fatal(err) - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } + archive := createContextTar(t, []contextTarEntry{ + {header: tar.Header{Name: "etc/dnf/", Typeflag: tar.TypeDir, Mode: 0755}}, + {header: tar.Header{Name: "build/server/deep/", Typeflag: tar.TypeDir, Mode: 0755}}, + {header: tar.Header{Name: "build/server/deep/Dockerfile", Typeflag: tar.TypeReg, Mode: 0644}, content: contents}, + {header: tar.Header{Name: "src/FieldLevel.SchemaChangeController/marker", Typeflag: tar.TypeReg, Mode: 0644}, content: "present"}, + }) destination := t.TempDir() scanner := &Scanner{destinationFolder: destination} - if err := scanner.getContextFromReader(&archive); err != nil { + if err := scanner.getContextFromReader(bytes.NewReader(archive)); err != nil { t.Fatalf("extract context with an implied parent directory: %v", err) } - got, err := os.ReadFile(filepath.Join(destination, "build", "server", "Dockerfile")) + if info, err := os.Stat(filepath.Join(destination, "etc", "dnf")); err != nil || !info.IsDir() { + t.Fatalf("etc/dnf was not extracted as a directory: %v", err) + } + got, err := os.ReadFile(filepath.Join(destination, "build", "server", "deep", "Dockerfile")) if err != nil { t.Fatal(err) } if string(got) != contents { t.Fatalf("Dockerfile contents = %q, want %q", got, contents) } + got, err = os.ReadFile(filepath.Join(destination, "src", "FieldLevel.SchemaChangeController", "marker")) + if err != nil { + t.Fatal(err) + } + if string(got) != "present" { + t.Fatalf("marker contents = %q, want present", got) + } +} + +func TestGetContextFromReaderUntarsAbsoluteLinks(t *testing.T) { + t.Run("symlink", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not support absolute Unix symlinks") + } + archive := createContextTar(t, []contextTarEntry{ + {header: tar.Header{Name: "var/run", Typeflag: tar.TypeSymlink, Linkname: "/run", Mode: 0777}}, + {header: tar.Header{Name: "var/run/existing/non-existing/file", Typeflag: tar.TypeReg, Mode: 0644}, content: "content"}, + }) + destination := t.TempDir() + scanner := &Scanner{destinationFolder: destination} + if err := scanner.getContextFromReader(bytes.NewReader(archive)); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(destination, "run", "existing", "non-existing", "file")) + if err != nil { + t.Fatal(err) + } + if string(got) != "content" { + t.Fatalf("extracted content = %q, want %q", got, "content") + } + target, err := os.Readlink(filepath.Join(destination, "var", "run")) + if err != nil { + t.Fatal(err) + } + if target != "/run" { + t.Fatalf("symlink target = %q, want /run", target) + } + }) + + t.Run("hardlink", func(t *testing.T) { + archive := createContextTar(t, []contextTarEntry{ + {header: tar.Header{Name: "usr/bin/perlbug", Typeflag: tar.TypeReg, Mode: 0755}, content: "hello"}, + {header: tar.Header{Name: "usr/bin/perlthanks", Typeflag: tar.TypeLink, Linkname: "/usr/bin/perlbug", Mode: 0755}}, + }) + destination := t.TempDir() + scanner := &Scanner{destinationFolder: destination} + if err := scanner.getContextFromReader(bytes.NewReader(archive)); err != nil { + t.Fatal(err) + } + target, err := os.Stat(filepath.Join(destination, "usr", "bin", "perlbug")) + if err != nil { + t.Fatal(err) + } + link, err := os.Stat(filepath.Join(destination, "usr", "bin", "perlthanks")) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(target, link) { + t.Fatal("absolute-target hardlink does not refer to the extracted file") + } + }) +} + +type contextTarEntry struct { + header tar.Header + content string +} + +func createContextTar(t *testing.T, entries []contextTarEntry) []byte { + t.Helper() + var archive bytes.Buffer + tw := tar.NewWriter(&archive) + for _, entry := range entries { + entry.header.Size = int64(len(entry.content)) + entry.header.Uid = os.Getuid() + entry.header.Gid = os.Getgid() + if err := tw.WriteHeader(&entry.header); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(entry.content)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return archive.Bytes() }