From e8917b1f9ed3a4c1a9e1a9d1eb0d27c00fe303f3 Mon Sep 17 00:00:00 2001 From: sdouglas Date: Thu, 29 Oct 2026 00:08:49 +0000 Subject: [PATCH 1/5] fix(security): reject path traversal in proxied request paths Go's ServeMux only cleans the escaped request path, so encoded dot segments (e.g. "..%2F", "%2E%2E") survive routing and appear decoded in r.PathValue and r.URL.Path. Several strategies spliced these values into upstream URLs, some of which carry credentials. Reject dot segments, backslashes and NUL bytes globally before OPA, and validate path values in the github-releases, git, hermit, host and artifactory strategies as defence in depth. Escape GitHub URL segments and build the hermit redirect URL structurally. Co-authored-by: Pi --- cmd/cachewd/main.go | 4 ++ internal/httputil/path.go | 48 +++++++++++++++++++++++ internal/httputil/path_test.go | 44 +++++++++++++++++++++ internal/strategy/artifactory.go | 4 ++ internal/strategy/git/git.go | 7 ++++ internal/strategy/github_releases.go | 31 +++++++++++++-- internal/strategy/github_releases_test.go | 20 ++++++++++ internal/strategy/hermit.go | 22 ++++++++--- internal/strategy/host.go | 4 ++ internal/strategy/host_test.go | 26 ++++++++++++ 10 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 internal/httputil/path.go create mode 100644 internal/httputil/path_test.go diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index bad875a1..0a7b69c2 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -27,6 +27,7 @@ import ( "github.com/block/cachew/internal/config" "github.com/block/cachew/internal/gitclone" "github.com/block/cachew/internal/githubapp" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/metadatadb" @@ -392,6 +393,9 @@ func newServer( return nil, errors.Errorf("initialise OPA middleware: %w", err) } + // Reject before OPA so policies can't be bypassed with encoded dot segments. + handler = httputil.RejectTraversal(handler) + // Add standard otelhttp middleware handler = otelhttp.NewMiddleware(metricsConfig.ServiceName, otelhttp.WithMeterProvider(otel.GetMeterProvider()), diff --git a/internal/httputil/path.go b/internal/httputil/path.go new file mode 100644 index 00000000..5141fe06 --- /dev/null +++ b/internal/httputil/path.go @@ -0,0 +1,48 @@ +package httputil + +import ( + "net/http" + "strings" + + "github.com/alecthomas/errors" +) + +// ValidatePath returns an error if the slash-separated path contains "." or +// ".." segments, backslashes, or NUL bytes. +// +// Go's ServeMux only cleans the escaped request path, so percent-encoded +// sequences such as "..%2F" or "%2E%2E" survive routing and appear verbatim +// in r.URL.Path and r.PathValue. Strategies that splice these values into +// upstream URLs or filesystem paths must reject them to prevent traversal. +func ValidatePath(path string) error { + if strings.ContainsAny(path, "\\\x00") { + return errors.Errorf("invalid path %q", path) + } + for segment := range strings.SplitSeq(path, "/") { + if segment == "." || segment == ".." { + return errors.Errorf("invalid path %q: contains dot segment", path) + } + } + return nil +} + +// ValidatePathSegment returns an error if s is not a single, non-empty path +// segment that is safe to splice into a URL or filesystem path. +func ValidatePathSegment(s string) error { + if s == "" || strings.Contains(s, "/") { + return errors.Errorf("invalid path segment %q", s) + } + return ValidatePath(s) +} + +// RejectTraversal wraps next, responding with 400 Bad Request to any request +// whose decoded URL path fails [ValidatePath]. +func RejectTraversal(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := ValidatePath(r.URL.Path); err != nil { + http.Error(w, "invalid request path", http.StatusBadRequest) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/httputil/path_test.go b/internal/httputil/path_test.go new file mode 100644 index 00000000..6bb580f4 --- /dev/null +++ b/internal/httputil/path_test.go @@ -0,0 +1,44 @@ +package httputil_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/httputil" +) + +func TestValidatePath(t *testing.T) { + for _, path := range []string{"", "/", "a/b/c", "/a/b.c/..d/e..", "a//b"} { + assert.NoError(t, httputil.ValidatePath(path), path) + } + for _, path := range []string{"..", ".", "a/../b", "/a/./b", "a/..", "../a", `a\..\b`, "a\x00b"} { + assert.Error(t, httputil.ValidatePath(path), path) + } +} + +func TestValidatePathSegment(t *testing.T) { + assert.NoError(t, httputil.ValidatePathSegment("github.com")) + for _, segment := range []string{"", ".", "..", "a/b", `a\b`} { + assert.Error(t, httputil.ValidatePathSegment(segment), segment) + } +} + +func TestRejectTraversal(t *testing.T) { + handler := httputil.RejectTraversal(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + tests := map[string]int{ + "/github.com/org/repo/releases/download/v1/file": http.StatusOK, + "/github.com/org/repo/releases/download/..%2F..%2Fx/file": http.StatusBadRequest, + "/github.com/org/%2E%2E/releases/download/v1/file": http.StatusBadRequest, + "/hermit/a.com/a%5C..%5Cb": http.StatusBadRequest, + } + for target, expected := range tests { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + assert.Equal(t, expected, w.Code, target) + } +} diff --git a/internal/strategy/artifactory.go b/internal/strategy/artifactory.go index 026d6fd5..b4901942 100644 --- a/internal/strategy/artifactory.go +++ b/internal/strategy/artifactory.go @@ -11,6 +11,7 @@ import ( "github.com/alecthomas/errors" "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/strategy/handler" ) @@ -110,6 +111,9 @@ func (a *Artifactory) String() string { return "artifactory:" + a.target.Host + // transformRequest transforms the incoming request before sending to upstream Artifactory. func (a *Artifactory) transformRequest(r *http.Request) (*http.Request, error) { + if err := httputil.ValidatePath(r.URL.Path); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } targetURL := a.buildTargetURL(r) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL.String(), nil) diff --git a/internal/strategy/git/git.go b/internal/strategy/git/git.go index d47fdb95..829fd395 100644 --- a/internal/strategy/git/git.go +++ b/internal/strategy/git/git.go @@ -29,6 +29,7 @@ import ( "github.com/block/cachew/internal/cache" "github.com/block/cachew/internal/gitclone" "github.com/block/cachew/internal/githubapp" + cachewhttputil "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/metadatadb" @@ -352,6 +353,12 @@ func (s *Strategy) handleRequest(w http.ResponseWriter, r *http.Request) { logger.DebugContext(ctx, "Git request", "method", r.Method, "host", host, "path", pathValue) + // host and path are spliced into upstream URLs that may carry GitHub App credentials. + if err := errors.Join(cachewhttputil.ValidatePathSegment(host), cachewhttputil.ValidatePath(pathValue)); err != nil { + cachewhttputil.ErrorResponse(w, r, http.StatusBadRequest, "invalid git request path") + return + } + if strings.HasSuffix(pathValue, EnsureRefsPath) { s.handleEnsureRefs(w, r, host, pathValue) return diff --git a/internal/strategy/github_releases.go b/internal/strategy/github_releases.go index a014e0d1..27e0cddc 100644 --- a/internal/strategy/github_releases.go +++ b/internal/strategy/github_releases.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "slices" "strings" @@ -90,9 +91,19 @@ func splitReleasePath(rest string) (release, file string) { return rest[:i], rest[i+1:] } +// escapePathSegments escapes each slash-separated segment of path so decoded +// characters such as "?" and "#" can't alter the structure of the URL. +func escapePathSegments(path string) string { + segments := strings.Split(path, "/") + for i, segment := range segments { + segments[i] = url.PathEscape(segment) + } + return strings.Join(segments, "/") +} + // newGitHubRequest creates a new HTTP request with GitHub API headers and authentication. -func (g *GitHubReleases) newGitHubRequest(ctx context.Context, url, accept, org string) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +func (g *GitHubReleases) newGitHubRequest(ctx context.Context, reqURL, accept, org string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, errors.Wrap(err, "create request") } @@ -122,6 +133,16 @@ func (g *GitHubReleases) newGitHubRequest(ctx context.Context, url, accept, org // For private orgs, it uses the GitHub API to find and download the asset. // For public orgs, it constructs a direct download URL. func (g *GitHubReleases) downloadRelease(ctx context.Context, org, repo, release, file string) (*http.Request, error) { + // These values are spliced into GitHub API URLs that may carry org credentials. + for _, segment := range []string{org, repo, file} { + if err := httputil.ValidatePathSegment(segment); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } + } + if err := httputil.ValidatePath(release); err != nil || release == "" { + return nil, httputil.Errorf(http.StatusBadRequest, "invalid release %q", release) + } + isPrivate := slices.Contains(g.config.PrivateOrgs, org) logger := logging.FromContext(ctx).With( @@ -130,7 +151,8 @@ func (g *GitHubReleases) downloadRelease(ctx context.Context, org, repo, release "release", release, "file", file) - realURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", org, repo, release, file) + realURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", + url.PathEscape(org), url.PathEscape(repo), escapePathSegments(release), url.PathEscape(file)) if !isPrivate { // Public release - use direct download URL logger.DebugContext(ctx, "Using public download URL") @@ -143,7 +165,8 @@ func (g *GitHubReleases) downloadRelease(ctx context.Context, org, repo, release // Use GitHub API to get release info and find the asset logger.DebugContext(ctx, "Using GitHub API for private release") - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", org, repo, release) + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", + url.PathEscape(org), url.PathEscape(repo), escapePathSegments(release)) req, err := g.newGitHubRequest(ctx, apiURL, "application/vnd.github+json", org) if err != nil { return nil, httputil.Errorf(http.StatusInternalServerError, "create API request") diff --git a/internal/strategy/github_releases_test.go b/internal/strategy/github_releases_test.go index d59980ff..7ee20134 100644 --- a/internal/strategy/github_releases_test.go +++ b/internal/strategy/github_releases_test.go @@ -287,3 +287,23 @@ func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { newReq.RequestURI = "" return t.originalTransport.RoundTrip(newReq) } + +func TestGitHubReleasesRejectsPathTraversal(t *testing.T) { + mock, mux, ctx := setupTest(t, strategy.GitHubReleasesConfig{ + Token: "test-token", + PrivateOrgs: []string{"privateorg"}, + }) + + for _, target := range []string{ + "/github.com/privateorg/repo/releases/download/..%2F..%2F..%2Fuser/binary.tar.gz", + "/github.com/privateorg/%2E%2E/releases/download/v1.0.0/binary.tar.gz", + "/github.com/privateorg/repo/releases/download/v1.0.0/%2E%2E", + } { + req := httptest.NewRequestWithContext(ctx, http.MethodGet, target, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code, target) + } + assert.Equal(t, 0, mock.apiCallCount) + assert.Equal(t, 0, mock.publicCallCount) +} diff --git a/internal/strategy/hermit.go b/internal/strategy/hermit.go index 2398b6c7..3994f8ee 100644 --- a/internal/strategy/hermit.go +++ b/internal/strategy/hermit.go @@ -12,6 +12,7 @@ import ( "github.com/alecthomas/errors" "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/strategy/handler" @@ -122,13 +123,20 @@ func (s *Hermit) buildGitHubURL(r *http.Request) string { } func (s *Hermit) buildRedirectRequest(r *http.Request) (*http.Request, error) { - path := ensureLeadingSlash(s.githubReleasePath(r)) - redirectURL := s.config.GitHubBaseURL + path - if r.URL.RawQuery != "" { - redirectURL += "?" + r.URL.RawQuery + // The redirect target may be cachew itself, so traversal could reach internal endpoints. + if err := errors.Join(httputil.ValidatePathSegment(r.PathValue("org")), + httputil.ValidatePathSegment(r.PathValue("repo")), httputil.ValidatePath(r.PathValue("path"))); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) } + redirectURL, err := url.Parse(s.config.GitHubBaseURL) + if err != nil { + return nil, errors.Wrap(err, "parse GitHub base URL") + } + redirectURL.Path = strings.TrimSuffix(redirectURL.Path, "/") + ensureLeadingSlash(s.githubReleasePath(r)) + redirectURL.RawPath = "" + redirectURL.RawQuery = r.URL.RawQuery - req, err := http.NewRequestWithContext(r.Context(), r.Method, redirectURL, nil) + req, err := http.NewRequestWithContext(r.Context(), r.Method, redirectURL.String(), nil) if err != nil { return nil, errors.Wrap(err, "create internal redirect request") } @@ -138,6 +146,10 @@ func (s *Hermit) buildRedirectRequest(r *http.Request) (*http.Request, error) { } func (s *Hermit) buildDirectRequest(r *http.Request) (*http.Request, error) { + if err := errors.Join(httputil.ValidatePathSegment(r.PathValue("host")), + httputil.ValidatePath(r.PathValue("path"))); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } originalURL := s.buildOriginalURL(r) s.logger.DebugContext(r.Context(), "Fetching Hermit package", "url", originalURL) diff --git a/internal/strategy/host.go b/internal/strategy/host.go index 71ddbf9c..e310b9b3 100644 --- a/internal/strategy/host.go +++ b/internal/strategy/host.go @@ -8,6 +8,7 @@ import ( "github.com/alecthomas/errors" "github.com/block/cachew/internal/cache" + "github.com/block/cachew/internal/httputil" "github.com/block/cachew/internal/strategy/handler" ) @@ -59,6 +60,9 @@ func NewHost(_ context.Context, config HostConfig, cache cache.Cache, mux Mux) ( return h.buildTargetURL(r).String() }). Transform(func(r *http.Request) (*http.Request, error) { + if err := httputil.ValidatePath(r.URL.Path); err != nil { + return nil, httputil.Errorf(http.StatusBadRequest, "%w", err) + } targetURL := h.buildTargetURL(r) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL.String(), nil) if err != nil { diff --git a/internal/strategy/host_test.go b/internal/strategy/host_test.go index a3bb9127..c398c717 100644 --- a/internal/strategy/host_test.go +++ b/internal/strategy/host_test.go @@ -143,3 +143,29 @@ func TestHostString(t *testing.T) { assert.Equal(t, "host:example.com/prefix", host.String()) } + +func TestHostRejectsPathTraversal(t *testing.T) { + callCount := 0 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + _, ctx := logging.Configure(context.Background(), logging.Config{Level: slog.LevelError}) + memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: time.Hour}) + assert.NoError(t, err) + defer memCache.Close() + + mux := http.NewServeMux() + _, err = strategy.NewHost(ctx, strategy.HostConfig{Target: backend.URL + "/prefix"}, memCache, mux) + assert.NoError(t, err) + + u, _ := url.Parse(backend.URL) + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/"+u.Host+"/prefix/..%2Fsecret", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, 0, callCount) +} From 6c07eacecadef67f598f72502d5dc9e780a86a96 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Thu, 29 Oct 2026 11:43:50 +1100 Subject: [PATCH 2/5] chore: drop comment on short helper Co-Authored-By: Claude Opus 5.5 --- internal/strategy/github_releases.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/strategy/github_releases.go b/internal/strategy/github_releases.go index 27e0cddc..2d96aa62 100644 --- a/internal/strategy/github_releases.go +++ b/internal/strategy/github_releases.go @@ -91,8 +91,6 @@ func splitReleasePath(rest string) (release, file string) { return rest[:i], rest[i+1:] } -// escapePathSegments escapes each slash-separated segment of path so decoded -// characters such as "?" and "#" can't alter the structure of the URL. func escapePathSegments(path string) string { segments := strings.Split(path, "/") for i, segment := range segments { From 0bf817ed5fea4b944380d65aecc9ef044370bd5a Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Thu, 29 Oct 2026 12:08:46 +1100 Subject: [PATCH 3/5] Harden archive path handling (#419) --- client/archive.go | 271 +++++++++++++++++++++++++++++--- client/archive_security_test.go | 87 ++++++++++ 2 files changed, 335 insertions(+), 23 deletions(-) create mode 100644 client/archive_security_test.go diff --git a/client/archive.go b/client/archive.go index 53b7d772..1e7a348d 100644 --- a/client/archive.go +++ b/client/archive.go @@ -1,6 +1,7 @@ package client import ( + "archive/tar" "bytes" "context" "fmt" @@ -10,6 +11,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "github.com/alecthomas/errors" ) @@ -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) { + 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) } @@ -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) @@ -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 { @@ -80,21 +82,16 @@ 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 @@ -102,25 +99,253 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r } 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) + 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) + 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 { + 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 diff --git a/client/archive_security_test.go b/client/archive_security_test.go new file mode 100644 index 00000000..a09ab6f2 --- /dev/null +++ b/client/archive_security_test.go @@ -0,0 +1,87 @@ +package client_test + +import ( + "archive/tar" + "bytes" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/client" +) + +func TestArchiveRejectsIncludePathsOutsideBase(t *testing.T) { + base := t.TempDir() + outside := filepath.Join(t.TempDir(), "secret.txt") + assert.NoError(t, os.WriteFile(outside, []byte("secret"), 0o600)) + + var out bytes.Buffer + assert.Error(t, client.Archive(t.Context(), &out, base, []string{"../secret.txt"}, nil, 0)) + assert.Error(t, client.Archive(t.Context(), &out, base, []string{outside}, nil, 0)) +} + +func TestExtractRejectsTraversalEntries(t *testing.T) { + parent := t.TempDir() + dst := filepath.Join(parent, "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "../../pwned", Mode: 0o600, Size: int64(len("pwned"))})) + _, err := tw.Write([]byte("pwned")) + assert.NoError(t, err) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Stat(filepath.Join(parent, "pwned")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractRejectsSymlinkTraversal(t *testing.T) { + parent := t.TempDir() + dst := filepath.Join(parent, "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "link/pwned", Mode: 0o600, Size: int64(len("pwned"))})) + _, err := tw.Write([]byte("pwned")) + assert.NoError(t, err) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Stat(filepath.Join(parent, "pwned")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractAllowsSymlinkWithinTarget(t *testing.T) { + dst := t.TempDir() + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "target", Mode: 0o600, Size: int64(len("ok"))})) + _, err := tw.Write([]byte("ok")) + assert.NoError(t, err) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "sub", Typeflag: tar.TypeDir, Mode: 0o755})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "sub/link", Typeflag: tar.TypeSymlink, Linkname: "../target", Mode: 0o777})) + }) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(payload), dst, 0)) + target, err := os.Readlink(filepath.Join(dst, "sub", "link")) + assert.NoError(t, err) + assert.Equal(t, "../target", target) +} + +func compressedTar(t *testing.T, writeEntries func(*tar.Writer)) []byte { + t.Helper() + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + writeEntries(tw) + assert.NoError(t, tw.Close()) + + cmd := exec.Command("zstd", "-q", "-c") + cmd.Stdin = &tarBuf + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + assert.NoError(t, cmd.Run(), stderr.String()) + return out.Bytes() +} From 218cfed6cbfe8ac9fc7618736b59b1da112c2d98 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Thu, 29 Oct 2026 13:03:03 +1100 Subject: [PATCH 4/5] fix(client): drain tar padding so zstd isn't killed by SIGPIPE The Go tar reader stops at the end-of-archive marker, leaving record padding unread. Closing the pipe while zstd was still writing it made Extract fail intermittently with "zstd failed: signal: broken pipe". Co-Authored-By: Claude Opus 5.5 --- client/archive.go | 6 ++++++ client/archive_security_test.go | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/client/archive.go b/client/archive.go index 1e7a348d..0d55b4e9 100644 --- a/client/archive.go +++ b/client/archive.go @@ -100,6 +100,12 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r pw.Close() //nolint:errcheck,gosec extractErr := safeExtractTar(ctx, pr, directory) + if extractErr == nil { + // tar.Reader stops at the end-of-archive marker; drain the record padding so zstd isn't killed by SIGPIPE. + if _, err := io.Copy(io.Discard, pr); err != nil { + extractErr = errors.Wrap(err, "failed to drain archive stream") + } + } pr.Close() //nolint:errcheck,gosec zstdErr := zstdCmd.Wait() diff --git a/client/archive_security_test.go b/client/archive_security_test.go index a09ab6f2..7077fd78 100644 --- a/client/archive_security_test.go +++ b/client/archive_security_test.go @@ -70,15 +70,36 @@ func TestExtractAllowsSymlinkWithinTarget(t *testing.T) { assert.Equal(t, "../target", target) } +func TestExtractConsumesTrailingPadding(t *testing.T) { + dst := t.TempDir() + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "file", Mode: 0o600, Size: int64(len("ok"))})) + _, err := tw.Write([]byte("ok")) + assert.NoError(t, err) + assert.NoError(t, tw.Close()) + // Larger than a pipe buffer, so zstd is still writing when the tar reader hits end-of-archive. + tarBuf.Write(make([]byte, 1<<20)) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(zstdCompress(t, tarBuf.Bytes())), dst, 0)) + content, err := os.ReadFile(filepath.Join(dst, "file")) + assert.NoError(t, err) + assert.Equal(t, "ok", string(content)) +} + func compressedTar(t *testing.T, writeEntries func(*tar.Writer)) []byte { t.Helper() var tarBuf bytes.Buffer tw := tar.NewWriter(&tarBuf) writeEntries(tw) assert.NoError(t, tw.Close()) + return zstdCompress(t, tarBuf.Bytes()) +} +func zstdCompress(t *testing.T, data []byte) []byte { + t.Helper() cmd := exec.Command("zstd", "-q", "-c") - cmd.Stdin = &tarBuf + cmd.Stdin = bytes.NewReader(data) var out, stderr bytes.Buffer cmd.Stdout = &out cmd.Stderr = &stderr From 592d5954ae2ee11a12c9922e0d0564779c09c284 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Thu, 29 Oct 2026 13:16:30 +1100 Subject: [PATCH 5/5] fix(client): confine hard-link sources and symlink chains, restore mtimes Hard-link sources only rejected a symlink in their final component, so a symlinked parent (pre-existing in the destination, or built from a chain of archived symlinks such as "a/b -> ..", "s -> a/b", "u -> s/..") let an archive link files from outside the destination. Check every parent of the link source, and reject symlink targets that use ".." after a name, since that resolves relative to wherever the name points rather than lexically. Also restore archived modification times on files and directories, which tar -xpf did before the Go extractor replaced it. Co-Authored-By: Claude Opus 5.5 --- client/archive.go | 51 ++++++++++++++---- client/archive_security_test.go | 91 +++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/client/archive.go b/client/archive.go index 0d55b4e9..3075c6c7 100644 --- a/client/archive.go +++ b/client/archive.go @@ -12,6 +12,7 @@ import ( "path/filepath" "runtime" "strings" + "time" "github.com/alecthomas/errors" ) @@ -56,7 +57,7 @@ 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 and symlinks. threads controls zstd parallelism; 0 uses all +// file permissions, modification times and symlinks. threads controls zstd parallelism; 0 uses all // CPU cores. // // Existing read-only directories are made owner-writable so entries can be @@ -121,7 +122,7 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) (r func safeExtractTar(ctx context.Context, r io.Reader, directory string) error { tr := tar.NewReader(r) - var dirs []dirMode + var dirs []extractedDir for { if err := ctx.Err(); err != nil { return errors.WithStack(err) @@ -140,16 +141,19 @@ func safeExtractTar(ctx context.Context, r io.Reader, directory string) error { } } -func applyDirModes(dirs []dirMode) error { +func applyDirModes(dirs []extractedDir) 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) } + if err := os.Chtimes(dirs[i].path, time.Time{}, dirs[i].modTime); err != nil { + return errors.Wrapf(err, "failed to set times on %s", dirs[i].path) + } } return nil } -func extractTarEntry(directory string, hdr *tar.Header, r io.Reader, dirs *[]dirMode) error { +func extractTarEntry(directory string, hdr *tar.Header, r io.Reader, dirs *[]extractedDir) error { target, rel, err := safeArchivePath(directory, hdr.Name) if err != nil { return err @@ -160,9 +164,9 @@ func extractTarEntry(directory string, hdr *tar.Header, r io.Reader, dirs *[]dir if err := mkdirAllWithin(directory, rel, mode|0o700); err != nil { return err } - *dirs = append(*dirs, dirMode{path: target, mode: mode}) + *dirs = append(*dirs, extractedDir{path: target, mode: mode, modTime: hdr.ModTime}) case tar.TypeReg, 0: - return extractRegularFile(directory, rel, target, mode, r) + return extractRegularFile(directory, rel, target, mode, hdr.ModTime, r) case tar.TypeSymlink: return extractSymlink(directory, rel, target, hdr.Linkname) case tar.TypeLink: @@ -179,7 +183,7 @@ 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 { +func extractRegularFile(directory, rel, target string, mode fs.FileMode, modTime time.Time, r io.Reader) error { if err := mkdirAllWithin(directory, filepath.Dir(rel), 0o750); err != nil { return err } @@ -192,7 +196,13 @@ func extractRegularFile(directory, rel, target string, mode fs.FileMode, r io.Re if err := removeExistingNonDir(target); err != nil { return err } - return writeRegularFile(target, mode, r) + if err := writeRegularFile(target, mode, r); err != nil { + return err + } + if err := os.Chtimes(target, time.Time{}, modTime); err != nil { + return errors.Wrapf(err, "failed to set times on %s", target) + } + return nil } func extractSymlink(directory, rel, target, linkname string) error { @@ -215,10 +225,13 @@ func extractSymlink(directory, rel, target, linkname string) error { } func extractHardLink(directory, rel, target, linkname string) error { - linkTarget, _, err := safeArchivePath(directory, linkname) + linkTarget, linkRel, err := safeArchivePath(directory, linkname) if err != nil { return err } + if err := ensureNoSymlinkParents(directory, linkRel); err != nil { + return err + } if err := rejectSymlink(linkTarget); err != nil { return err } @@ -257,6 +270,20 @@ func validateSymlinkTarget(rel, linkname string) error { 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) } + // ".." after a name would resolve relative to wherever that name points, so chained symlinks + // such as "a/b -> ..", "s -> a/b", "u -> s/.." could escape despite each being lexically local. + sawName := false + for part := range strings.SplitSeq(linkname, "/") { + switch part { + case "", ".": + case "..": + if sawName { + return errors.Errorf("symlink target %q must only use \"..\" as leading components", linkname) + } + default: + sawName = true + } + } return nil } @@ -352,6 +379,12 @@ func writeRegularFile(path string, mode fs.FileMode, r io.Reader) (rerr error) { return nil } +type extractedDir struct { + path string + mode fs.FileMode + modTime time.Time +} + // dirMode is a directory path and its pre-relaxation mode. type dirMode struct { path string diff --git a/client/archive_security_test.go b/client/archive_security_test.go index 7077fd78..8790e4d7 100644 --- a/client/archive_security_test.go +++ b/client/archive_security_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "testing" + "time" "github.com/alecthomas/assert/v2" @@ -70,6 +71,96 @@ func TestExtractAllowsSymlinkWithinTarget(t *testing.T) { assert.Equal(t, "../target", target) } +func TestExtractRejectsHardLinkThroughSymlinkedParent(t *testing.T) { + parent := t.TempDir() + outside := filepath.Join(parent, "outside") + assert.NoError(t, os.Mkdir(outside, 0o750)) + assert.NoError(t, os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0o600)) + dst := filepath.Join(parent, "dst") + assert.NoError(t, os.Mkdir(dst, 0o750)) + assert.NoError(t, os.Symlink(outside, filepath.Join(dst, "escape"))) + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "stolen", Typeflag: tar.TypeLink, Linkname: "escape/secret"})) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Lstat(filepath.Join(dst, "stolen")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractRejectsChainedSymlinkEscape(t *testing.T) { + for _, linkname := range []string{"s/..", "s/m/../..", "./s/.."} { + t.Run(linkname, func(t *testing.T) { + dst := filepath.Join(t.TempDir(), "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a", Typeflag: tar.TypeDir, Mode: 0o755})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a/b", Typeflag: tar.TypeSymlink, Linkname: ".."})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "s", Typeflag: tar.TypeSymlink, Linkname: "a/b"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "u", Typeflag: tar.TypeSymlink, Linkname: linkname})) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Lstat(filepath.Join(dst, "u")) + assert.True(t, os.IsNotExist(statErr)) + }) + } +} + +func TestExtractRejectsHardLinkThroughArchivedSymlinkChain(t *testing.T) { + parent := t.TempDir() + assert.NoError(t, os.WriteFile(filepath.Join(parent, "secret"), []byte("secret"), 0o600)) + dst := filepath.Join(parent, "dst") + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a", Typeflag: tar.TypeDir, Mode: 0o755})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "a/b", Typeflag: tar.TypeSymlink, Linkname: ".."})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "s", Typeflag: tar.TypeSymlink, Linkname: "a/b"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "u", Typeflag: tar.TypeSymlink, Linkname: "s/.."})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "stolen", Typeflag: tar.TypeLink, Linkname: "u/secret"})) + }) + + err := client.Extract(t.Context(), bytes.NewReader(payload), dst, 0) + assert.Error(t, err) + _, statErr := os.Lstat(filepath.Join(dst, "stolen")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestExtractAllowsDanglingAndLoopingSymlinks(t *testing.T) { + dst := t.TempDir() + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "dangling", Typeflag: tar.TypeSymlink, Linkname: "missing"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "sub/up", Typeflag: tar.TypeSymlink, Linkname: "./../dangling"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "loop1", Typeflag: tar.TypeSymlink, Linkname: "loop2"})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "loop2", Typeflag: tar.TypeSymlink, Linkname: "loop1"})) + }) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(payload), dst, 0)) + target, err := os.Readlink(filepath.Join(dst, "dangling")) + assert.NoError(t, err) + assert.Equal(t, "missing", target) +} + +func TestExtractRestoresModTimes(t *testing.T) { + dst := t.TempDir() + modTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + payload := compressedTar(t, func(tw *tar.Writer) { + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "dir", Typeflag: tar.TypeDir, Mode: 0o555, ModTime: modTime})) + assert.NoError(t, tw.WriteHeader(&tar.Header{Name: "dir/file", Mode: 0o644, Size: int64(len("ok")), ModTime: modTime})) + _, err := tw.Write([]byte("ok")) + assert.NoError(t, err) + }) + + assert.NoError(t, client.Extract(t.Context(), bytes.NewReader(payload), dst, 0)) + t.Cleanup(func() { os.Chmod(filepath.Join(dst, "dir"), 0o755) }) //nolint:errcheck,gosec + fileInfo, err := os.Stat(filepath.Join(dst, "dir", "file")) + assert.NoError(t, err) + assert.True(t, fileInfo.ModTime().Equal(modTime), "file mtime %s", fileInfo.ModTime()) + dirInfo, err := os.Stat(filepath.Join(dst, "dir")) + assert.NoError(t, err) + assert.True(t, dirInfo.ModTime().Equal(modTime), "dir mtime %s", dirInfo.ModTime()) +} + func TestExtractConsumesTrailingPadding(t *testing.T) { dst := t.TempDir() var tarBuf bytes.Buffer