Repository: kubernetes-csi/csi-driver-nfs
Pull request #808 (2024-12-03 08:36:46)

feat: replace tar (cli) with code-only packing
**What type of PR is this?**
/kind feature

**What this PR does / why we need it**:

- Removes dependency on the presence of `tar` utility in the system. This is beneficial for distroless containers
- New packing/unpacking gives more control over the content of snapshots and make developers think about what exactly is being snapshotted/restored

**Special notes for your reviewer**:

- One of the important cases I've tried to support - proper symlink support. Unfortunately, symlinks happen to be not supported in the testing solution I've chosen (`golang.org/x/mod/sumdb/dirhash`), so this functionality is only tested by me manually. This can be improved, if we'll find a way to hash the resulting directories considering symlinks. I've also tried to byte-compare the resulting archives, but they contain non-deterministic information, and, therefore, not comparable.

- Another important case was reported by an automatic check - Arbitrary file access during archive extraction ("Zip Slip"). Issue was reproduced in test and fixed. I've chosen the error, which won't change in future versions of Go (after they will finally "turn on" their fix by default, see [issue](https://github.com/golang/go/issues/55356))

**Does this PR introduce a user-facing change?**:
```release-note
tar is no longer needed to be present in the environment for snapshots to work
```


k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-csi/csi-driver-nfs/pull/808#" title="Author self-approved">astef</a>*
**Once this PR has been reviewed and has the lgtm label**, please assign [xing-yang](https://github.com/xing-yang) for approval. For more information see [the Kubernetes Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-csi%2Fcsi-driver-nfs).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-csi/csi-driver-nfs/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["xing-yang"]} -->
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
k8s-ci-robot: Welcome @astef! <br><br>It looks like this is your first PR to <a href='https://github.com/kubernetes-csi/csi-driver-nfs'>kubernetes-csi/csi-driver-nfs</a> 🎉. Please refer to our [pull request process documentation](https://git.k8s.io/community/contributors/guide/pull-requests.md) to help your PR have a smooth ride to approval. <br><br>You will be prompted by a bot to use commands during the review process. Do not be afraid to follow the prompts! It is okay to experiment. [Here is the bot commands documentation](https://go.k8s.io/bot-commands). <br><br>You can also check if kubernetes-csi/csi-driver-nfs has [its own contribution guidelines](https://github.com/kubernetes-csi/csi-driver-nfs/tree/master/CONTRIBUTING.md). <br><br>You may want to refer to our [testing guide](https://git.k8s.io/community/contributors/devel/sig-testing/testing.md) if you run into trouble with your tests not passing. <br><br>If you are having difficulty getting your pull request seen, please follow the [recommended escalation practices](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#why-is-my-pull-request-not-getting-reviewed). Also, for tips and tricks in the contribution process you may want to read the [Kubernetes contributor cheat sheet](https://git.k8s.io/community/contributors/guide/contributor-cheatsheet/README.md). We want to make sure your contribution gets all the attention it needs! <br><br>Thank you, and welcome to Kubernetes. :smiley:
k8s-ci-robot: Hi @astef. Thanks for your PR.

I'm waiting for a [kubernetes-csi](https://github.com/orgs/kubernetes-csi/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://git.k8s.io/community/community-membership.md#member) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=kubernetes-csi%2Fcsi-driver-nfs).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
andyzhangx: /ok-to-test
coveralls: ## Pull Request Test Coverage Report for [Build 12291963441](https://coveralls.io/builds/71317587)


### Details

* **124** of **167**   **(74.25%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.6%**) to **77.98%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [pkg/nfs/tar.go](https://coveralls.io/builds/71317587/source?filename=pkg%2Fnfs%2Ftar.go#L36) | 118 | 161 | 73.29%
<!-- | **Total:** | **124** | **167** | **74.25%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/71317587/badge)](https://coveralls.io/builds/71317587) |
| :-- | --: |
| Change from base [Build 12275254886](https://coveralls.io/builds/71299836): |  -0.6% |
| Covered Lines: | 1073 |
| Relevant Lines: | 1376 |

---
##### 💛  - [Coveralls](https://coveralls.io)

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/20677718/149336667/808/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-missing-id.svg" alt="CLA Missing ID" align="left" height="28" width="328"></a> <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/20677718/149336667/808/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-not-signed.svg" alt="CLA Not Signed" align="left" height="28" width="328"></a><br/><br /><ul><li> :x: The email address for the commit (90decac2db3f46ac70f64eb2ade809bd002b803b) is not linked to the GitHub account, preventing the EasyCLA check. Consult <a href='https://linuxfoundation.atlassian.net/wiki/spaces/LP/pages/160923756/Missing+ID+on+Commit+but+I+have+an+agreement+on+file' target='_blank'>this Help Article</a> and <a href='https://help.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user' target='_blank'>GitHub Help</a> to resolve. (To view the commit's email address, add .patch at the end of this PR page's URL.) For further assistance with EasyCLA, <a href='https://jira.linuxfoundation.org/servicedesk/customer/portal/4' target='_blank'>please submit a support request ticket</a>.</li></ul><!-- Date Modified: 2024-12-12 08:44:16.437469 -->
andyzhangx: @astef can you sign the cla first? thx
astef: I had to change my email in GitHub, that's why EasyCLA now fails. Will fix it soon.
k8s-ci-robot: @astef: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-kubernetes-csi-csi-driver-nfs | 90decac2db3f46ac70f64eb2ade809bd002b803b | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-csi_csi-driver-nfs/808/pull-kubernetes-csi-csi-driver-nfs/1867128472015474688) | true | `/test pull-kubernetes-csi-csi-driver-nfs`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-csi&repo=csi-driver-nfs&pr=808). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Aastef). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-csi/csi-driver-nfs/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @astef: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-kubernetes-csi-csi-driver-nfs | d4c9768e81bd893a791db634877beedae3f03eef | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-csi_csi-driver-nfs/808/pull-kubernetes-csi-csi-driver-nfs/1867136252755578880) | true | `/test pull-kubernetes-csi-csi-driver-nfs`
pull-csi-driver-nfs-external-e2e | d4c9768e81bd893a791db634877beedae3f03eef | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-csi_csi-driver-nfs/808/pull-csi-driver-nfs-external-e2e/1867136252973682688) | true | `/test pull-csi-driver-nfs-external-e2e`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-csi&repo=csi-driver-nfs&pr=808). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Aastef). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-csi/csi-driver-nfs/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
astef: @andyzhangx I've just finished manual test in my cluster (snapshot/restore, checked integrity of content, folder structure inside controller), and removed "WIP" prefix. Sorry for delay and back-and-forth.
andyzhangx: @astef thanks for the contribution, does original tar command supports symlink?
astef: > does original tar command supports symlink?
Yes it does, here's how I check:
```
# setup
mkdir input && date > input/d.txt && cd input && ln -s -T ./d.txt ./symlink_to_d.txt && cd ..

# pack
tar -C input/ -czvf output.tar.gz .

# unpack
mkdir output && tar -xzvf output.tar.gz -C output

# check
ls -la input && ls -la output && cat input/symlink_to_d.txt && cat output/symlink_to_d.txt
```

astef: But proposed solution supports symlinks too. In "special notes" I've meant that I'm not testing symlinks in unit-tests, but they're suppored.

Actually, now I think that I can write a more concrete unit-test, without `golang.org/x/mod/sumdb/dirhash`, and this will demonstrate the support of symlinks, and increase coverage.
astef: @andyzhangx you were right, there was a regression. Up to this moment I didn't realize that `io.Copy` won't create a symlink's "content" automatically. I saw it was created, but missed that it's empty. Fixed and covered with test. Thank you!
k8s-ci-robot: @astef: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-csi-driver-nfs-external-e2e | 6c144e2af26b31b1870328cff95092ffe745ffcc | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-csi_csi-driver-nfs/808/pull-csi-driver-nfs-external-e2e/1868736970209890304) | true | `/test pull-csi-driver-nfs-external-e2e`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-csi&repo=csi-driver-nfs&pr=808). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Aastef). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-csi/csi-driver-nfs/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
astef: @andyzhangx I'm little bit worried this PR will be lost due to the delays on my side. Letting you know, that I've finished and ready for more questions and feedback.

Also, worth noting that we've [merged this patch](https://github.com/deckhouse/csi-nfs/pull/54) to our product, and it has already been deployed to production environments, and, of course, we will keep contributing fixes/improvements, if any.
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

Patch:
diff --git a/go.mod b/go.mod
index 880c9c88..97228b7a 100644
--- a/go.mod
+++ b/go.mod
@@ -97,6 +97,7 @@ require (
 	go.uber.org/zap v1.19.0 // indirect
 	golang.org/x/crypto v0.32.0 // indirect
 	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+	golang.org/x/mod v0.22.0
 	golang.org/x/oauth2 v0.23.0 // indirect
 	golang.org/x/sync v0.10.0 // indirect
 	golang.org/x/sys v0.29.0 // indirect
diff --git a/go.sum b/go.sum
index 14e08fa8..3671abc4 100644
--- a/go.sum
+++ b/go.sum
@@ -457,6 +457,8 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
 golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
+golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
diff --git a/pkg/nfs/controllerserver.go b/pkg/nfs/controllerserver.go
index adb5049d..50c0ee25 100644
--- a/pkg/nfs/controllerserver.go
+++ b/pkg/nfs/controllerserver.go
@@ -404,10 +404,11 @@ func (cs *ControllerServer) CreateSnapshot(ctx context.Context, req *csi.CreateS
 
 	srcPath := getInternalVolumePath(cs.Driver.workingMountDir, srcVol)
 	dstPath := filepath.Join(snapInternalVolPath, snapshot.archiveName())
+
 	klog.V(2).Infof("tar %v -> %v", srcPath, dstPath)
-	out, err := exec.Command("tar", "-C", srcPath, "-czvf", dstPath, ".").CombinedOutput()
+	err = TarPack(srcPath, dstPath, true)
 	if err != nil {
-		return nil, status.Errorf(codes.Internal, "failed to create archive for snapshot: %v: %v", err, string(out))
+		return nil, status.Errorf(codes.Internal, "failed to create archive for snapshot: %v", err)
 	}
 	klog.V(2).Infof("tar %s -> %s complete", srcPath, dstPath)
 
@@ -571,9 +572,10 @@ func (cs *ControllerServer) copyFromSnapshot(ctx context.Context, req *csi.Creat
 	snapPath := filepath.Join(getInternalVolumePath(cs.Driver.workingMountDir, snapVol), snap.archiveName())
 	dstPath := getInternalVolumePath(cs.Driver.workingMountDir, dstVol)
 	klog.V(2).Infof("copy volume from snapshot %v -> %v", snapPath, dstPath)
-	out, err := exec.Command("tar", "-xzvf", snapPath, "-C", dstPath).CombinedOutput()
+
+	err = TarUnpack(snapPath, dstPath, true)
 	if err != nil {
-		return status.Errorf(codes.Internal, "failed to copy volume for snapshot: %v: %v", err, string(out))
+		return status.Errorf(codes.Internal, "failed to copy volume for snapshot: %v", err)
 	}
 	klog.V(2).Infof("volume copied from snapshot %v -> %v", snapPath, dstPath)
 	return nil
diff --git a/pkg/nfs/tar.go b/pkg/nfs/tar.go
new file mode 100644
index 00000000..ef4171d7
--- /dev/null
+++ b/pkg/nfs/tar.go
@@ -0,0 +1,246 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package nfs
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+func TarPack(srcDirPath string, dstPath string, enableCompression bool) error {
+	// normalize all paths to be absolute and clean
+	dstPath, err := filepath.Abs(dstPath)
+	if err != nil {
+		return fmt.Errorf("normalizing destination path: %w", err)
+	}
+
+	srcDirPath, err = filepath.Abs(srcDirPath)
+	if err != nil {
+		return fmt.Errorf("normalizing source path: %w", err)
+	}
+
+	if strings.HasPrefix(filepath.Dir(dstPath), srcDirPath) {
+		return fmt.Errorf("destination file %s cannot be under source directory %s", dstPath, srcDirPath)
+	}
+
+	tarFile, err := os.Create(dstPath)
+	if err != nil {
+		return fmt.Errorf("creating destination file: %w", err)
+	}
+	defer func() {
+		err = errors.Join(err, closeAndWrapErr(tarFile, "closing destination file %s: %w", dstPath))
+	}()
+
+	var tarDst io.Writer = tarFile
+	if enableCompression {
+		gzipWriter := gzip.NewWriter(tarFile)
+		defer func() {
+			err = errors.Join(err, closeAndWrapErr(gzipWriter, "closing gzip writer"))
+		}()
+		tarDst = gzipWriter
+	}
+
+	tarWriter := tar.NewWriter(tarDst)
+	defer func() {
+		err = errors.Join(err, closeAndWrapErr(tarWriter, "closing tar writer"))
+	}()
+
+	// recursively visit every file and write it
+	if err = filepath.Walk(
+		srcDirPath,
+		func(srcSubPath string, fileInfo fs.FileInfo, walkErr error) error {
+			return tarVisitFileToPack(tarWriter, srcDirPath, srcSubPath, fileInfo, walkErr)
+		},
+	); err != nil {
+		return fmt.Errorf("walking source directory: %w", err)
+	}
+
+	return nil
+}
+
+func tarVisitFileToPack(
+	tarWriter *tar.Writer,
+	srcPath string,
+	srcSubPath string,
+	fileInfo os.FileInfo,
+	walkErr error,
+) (err error) {
+	if walkErr != nil {
+		return walkErr
+	}
+
+	linkTarget := ""
+	if fileInfo.Mode()&fs.ModeSymlink != 0 {
+		linkTarget, err = os.Readlink(srcSubPath)
+		if err != nil {
+			return fmt.Errorf("reading link %s: %w", srcSubPath, err)
+		}
+	}
+
+	tarHeader, err := tar.FileInfoHeader(fileInfo, linkTarget)
+	if err != nil {
+		return fmt.Errorf("creating tar header for %s: %w", srcSubPath, err)
+	}
+
+	// srcSubPath always starts with srcPath and both are absolute
+	tarHeader.Name, err = filepath.Rel(srcPath, srcSubPath)
+	if err != nil {
+		return fmt.Errorf("making tar header name for file %s: %w", srcSubPath, err)
+	}
+
+	if err = tarWriter.WriteHeader(tarHeader); err != nil {
+		return fmt.Errorf("writing tar header for file %s: %w", srcSubPath, err)
+	}
+
+	if !fileInfo.Mode().IsRegular() {
+		return nil
+	}
+
+	srcFile, err := os.Open(srcSubPath)
+	if err != nil {
+		return fmt.Errorf("opening file being packed %s: %w", srcSubPath, err)
+	}
+	defer func() {
+		err = errors.Join(err, closeAndWrapErr(srcFile, "closing file being packed %s: %w", srcSubPath))
+	}()
+	_, err = io.Copy(tarWriter, srcFile)
+	if err != nil {
+		return fmt.Errorf("packing file %s: %w", srcSubPath, err)
+	}
+	return nil
+}
+
+func TarUnpack(srcPath, dstDirPath string, enableCompression bool) (err error) {
+	// normalize all paths to be absolute and clean
+	srcPath, err = filepath.Abs(srcPath)
+	if err != nil {
+		return fmt.Errorf("normalizing archive path: %w", err)
+	}
+
+	dstDirPath, err = filepath.Abs(dstDirPath)
+	if err != nil {
+		return fmt.Errorf("normalizing archive destination path: %w", err)
+	}
+
+	tarFile, err := os.Open(srcPath)
+	if err != nil {
+		return fmt.Errorf("opening archive %s: %w", srcPath, err)
+	}
+	defer func() {
+		err = errors.Join(err, closeAndWrapErr(tarFile, "closing archive %s: %w", srcPath))
+	}()
+
+	var tarDst io.Reader = tarFile
+	if enableCompression {
+		var gzipReader *gzip.Reader
+		gzipReader, err = gzip.NewReader(tarFile)
+		if err != nil {
+			return fmt.Errorf("creating gzip reader: %w", err)
+		}
+		defer func() {
+			err = errors.Join(err, closeAndWrapErr(gzipReader, "closing gzip reader: %w"))
+		}()
+
+		tarDst = gzipReader
+	}
+
+	tarReader := tar.NewReader(tarDst)
+
+	for {
+		var tarHeader *tar.Header
+		tarHeader, err = tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return fmt.Errorf("reading tar header of %s: %w", srcPath, err)
+		}
+
+		fileInfo := tarHeader.FileInfo()
+
+		filePath := filepath.Join(dstDirPath, tarHeader.Name)
+
+		// protect against "Zip Slip"
+		if !strings.HasPrefix(filePath, dstDirPath) {
+			// mimic standard error, which will be returned in future versions of Go by default
+			// more info can be found by "tarinsecurepath" variable name
+			return tar.ErrInsecurePath
+		}
+
+		fileDirPath := filePath
+		if !fileInfo.Mode().IsDir() {
+			fileDirPath = filepath.Dir(fileDirPath)
+		}
+
+		if err = os.MkdirAll(fileDirPath, 0755); err != nil {
+			return fmt.Errorf("making dirs for path %s: %w", fileDirPath, err)
+		}
+
+		if fileInfo.Mode().IsDir() {
+			continue
+		}
+
+		if fileInfo.Mode()&fs.ModeSymlink != 0 {
+			if err := os.Symlink(tarHeader.Linkname, filePath); err != nil {
+				return fmt.Errorf("creating symlink %s: %w", filePath, err)
+			}
+			continue
+		}
+
+		if err = tarUnpackFile(filePath, tarReader, fileInfo); err != nil {
+			return fmt.Errorf("unpacking file %s: %w", filePath, err)
+		}
+	}
+	return nil
+}
+
+func tarUnpackFile(dstFileName string, src io.Reader, srcFileInfo fs.FileInfo) (err error) {
+	var dstFile *os.File
+	dstFile, err = os.OpenFile(dstFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, srcFileInfo.Mode().Perm())
+	if err != nil {
+		return fmt.Errorf("opening destination file %s: %w", dstFileName, err)
+	}
+	defer func() {
+		err = errors.Join(err, closeAndWrapErr(dstFile, "closing destination file %s: %w", dstFile))
+	}()
+
+	n, err := io.Copy(dstFile, src)
+	if err != nil {
+		return fmt.Errorf("copying to destination file %s: %w", dstFileName, err)
+	}
+
+	if srcFileInfo.Mode().IsRegular() && n != srcFileInfo.Size() {
+		return fmt.Errorf("written size check failed for %s: wrote %d, want %d", dstFileName, n, srcFileInfo.Size())
+	}
+
+	return nil
+}
+
+func closeAndWrapErr(closer io.Closer, errFormat string, a ...any) error {
+	if err := closer.Close(); err != nil {
+		a = append(a, err)
+		return fmt.Errorf(errFormat, a...)
+	}
+	return nil
+}
diff --git a/vendor/golang.org/x/mod/LICENSE b/vendor/golang.org/x/mod/LICENSE
new file mode 100644
index 00000000..2a7cf70d
--- /dev/null
+++ b/vendor/golang.org/x/mod/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/vendor/golang.org/x/mod/PATENTS b/vendor/golang.org/x/mod/PATENTS
new file mode 100644
index 00000000..73309904
--- /dev/null
+++ b/vendor/golang.org/x/mod/PATENTS
@@ -0,0 +1,22 @@
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go.  This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation.  If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
diff --git a/vendor/golang.org/x/mod/sumdb/dirhash/hash.go b/vendor/golang.org/x/mod/sumdb/dirhash/hash.go
new file mode 100644
index 00000000..51ec4db8
--- /dev/null
+++ b/vendor/golang.org/x/mod/sumdb/dirhash/hash.go
@@ -0,0 +1,135 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package dirhash defines hashes over directory trees.
+// These hashes are recorded in go.sum files and in the Go checksum database,
+// to allow verifying that a newly-downloaded module has the expected content.
+package dirhash
+
+import (
+	"archive/zip"
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+// DefaultHash is the default hash function used in new go.sum entries.
+var DefaultHash Hash = Hash1
+
+// A Hash is a directory hash function.
+// It accepts a list of files along with a function that opens the content of each file.
+// It opens, reads, hashes, and closes each file and returns the overall directory hash.
+type Hash func(files []string, open func(string) (io.ReadCloser, error)) (string, error)
+
+// Hash1 is the "h1:" directory hash function, using SHA-256.
+//
+// Hash1 is "h1:" followed by the base64-encoded SHA-256 hash of a summary
+// prepared as if by the Unix command:
+//
+//	sha256sum $(find . -type f | sort) | sha256sum
+//
+// More precisely, the hashed summary contains a single line for each file in the list,
+// ordered by sort.Strings applied to the file names, where each line consists of
+// the hexadecimal SHA-256 hash of the file content,
+// two spaces (U+0020), the file name, and a newline (U+000A).
+//
+// File names with newlines (U+000A) are disallowed.
+func Hash1(files []string, open func(string) (io.ReadCloser, error)) (string, error) {
+	h := sha256.New()
+	files = append([]string(nil), files...)
+	sort.Strings(files)
+	for _, file := range files {
+		if strings.Contains(file, "\n") {
+			return "", errors.New("dirhash: filenames with newlines are not supported")
+		}
+		r, err := open(file)
+		if err != nil {
+			return "", err
+		}
+		hf := sha256.New()
+		_, err = io.Copy(hf, r)
+		r.Close()
+		if err != nil {
+			return "", err
+		}
+		fmt.Fprintf(h, "%x  %s\n", hf.Sum(nil), file)
+	}
+	return "h1:" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
+}
+
+// HashDir returns the hash of the local file system directory dir,
+// replacing the directory name itself with prefix in the file names
+// used in the hash function.
+func HashDir(dir, prefix string, hash Hash) (string, error) {
+	files, err := DirFiles(dir, prefix)
+	if err != nil {
+		return "", err
+	}
+	osOpen := func(name string) (io.ReadCloser, error) {
+		return os.Open(filepath.Join(dir, strings.TrimPrefix(name, prefix)))
+	}
+	return hash(files, osOpen)
+}
+
+// DirFiles returns the list of files in the tree rooted at dir,
+// replacing the directory name dir with prefix in each name.
+// The resulting names always use forward slashes.
+func DirFiles(dir, prefix string) ([]string, error) {
+	var files []string
+	dir = filepath.Clean(dir)
+	err := filepath.Walk(dir, func(file string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		} else if file == dir {
+			return fmt.Errorf("%s is not a directory", dir)
+		}
+
+		rel := file
+		if dir != "." {
+			rel = file[len(dir)+1:]
+		}
+		f := filepath.Join(prefix, rel)
+		files = append(files, filepath.ToSlash(f))
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return files, nil
+}
+
+// HashZip returns the hash of the file content in the named zip file.
+// Only the file names and their contents are included in the hash:
+// the exact zip file format encoding, compression method,
+// per-file modification times, and other metadata are ignored.
+func HashZip(zipfile string, hash Hash) (string, error) {
+	z, err := zip.OpenReader(zipfile)
+	if err != nil {
+		return "", err
+	}
+	defer z.Close()
+	var files []string
+	zfiles := make(map[string]*zip.File)
+	for _, file := range z.File {
+		files = append(files, file.Name)
+		zfiles[file.Name] = file
+	}
+	zipOpen := func(name string) (io.ReadCloser, error) {
+		f := zfiles[name]
+		if f == nil {
+			return nil, fmt.Errorf("file %q not found in zip", name) // should never happen
+		}
+		return f.Open()
+	}
+	return hash(files, zipOpen)
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index c0b70a5b..8e2f0e21 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -381,6 +381,9 @@ golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
 ## explicit; go 1.20
 golang.org/x/exp/constraints
 golang.org/x/exp/slices
+# golang.org/x/mod v0.22.0
+## explicit; go 1.22.0
+golang.org/x/mod/sumdb/dirhash
 # golang.org/x/net v0.34.0
 ## explicit; go 1.18
 golang.org/x/net/context


Test patch:
diff --git a/pkg/nfs/tar_test.go b/pkg/nfs/tar_test.go
new file mode 100644
index 00000000..0330c014
--- /dev/null
+++ b/pkg/nfs/tar_test.go
@@ -0,0 +1,350 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package nfs
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"errors"
+	"fmt"
+	"maps"
+	"math"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"slices"
+	"strings"
+	"testing"
+	"time"
+
+	"golang.org/x/mod/sumdb/dirhash"
+)
+
+const (
+	code producedFrom = '0'
+	cli  producedFrom = '1'
+)
+
+type producedFrom byte
+
+const archiveFileExt = ".tar.gz"
+
+func TestPackUnpack(t *testing.T) {
+	inputPath := t.TempDir()
+	generateFileSystem(t, inputPath)
+
+	outputPath := t.TempDir()
+
+	// produced file names (without extensions) have a suffix,
+	// which determine the last operation:
+	// "0" means that it was produced from code
+	// "1" means that it was produced from CLI
+	// e.g.: "testdata011.tar.gz" - was packed from code,
+	// then unpacked from cli and packed again from cli
+
+	pathsBySuffix := make(map[string]string)
+
+	// number of pack/unpack operations
+	opNum := 4
+
+	// generate all operation combinations
+	fileNum := int(math.Pow(2, float64(opNum)))
+	for i := 0; i < fileNum; i++ {
+		binStr := fmt.Sprintf("%b", i)
+
+		// left-pad with zeroes
+		binStr = strings.Repeat("0", opNum-len(binStr)) + binStr
+
+		// copy slices to satisfy type system
+		ops := make([]producedFrom, opNum)
+		for opIdx := 0; opIdx < opNum; opIdx++ {
+			ops[opIdx] = producedFrom(binStr[opIdx])
+		}
+
+		// produce folders and archives
+		produce(t, pathsBySuffix, inputPath, outputPath, ops...)
+	}
+
+	// compare all unpacked directories
+	paths := slices.Collect(maps.Values(pathsBySuffix))
+	assertUnpackedFilesEqual(t, inputPath, paths)
+}
+
+func produce(
+	t *testing.T,
+	results map[string]string,
+	inputDirPath string,
+	outputDirPath string,
+	ops ...producedFrom,
+) {
+	const baseName = "testdata"
+
+	for i := 0; i < len(ops); i++ {
+		packing := i%2 == 0
+
+		srcPath := inputDirPath
+		if i > 0 {
+			prevSuffix := string(ops[:i])
+			srcPath = filepath.Join(outputDirPath, baseName+prevSuffix)
+			if !packing {
+				srcPath += archiveFileExt
+			}
+		}
+
+		suffix := string(ops[:i+1])
+		dstPath := filepath.Join(outputDirPath, baseName+suffix)
+		if packing {
+			dstPath += archiveFileExt
+		}
+
+		if _, ok := results[suffix]; ok {
+			continue
+		}
+
+		switch {
+		case packing && ops[i] == code:
+			// packing from code
+			if err := TarPack(srcPath, dstPath, true); err != nil {
+				t.Fatalf("packing '%s' with TarPack into '%s': %v", srcPath, dstPath, err)
+			}
+		case packing && ops[i] == cli:
+			// packing from CLI
+			if out, err := exec.Command("tar", "-C", srcPath, "-czvf", dstPath, ".").CombinedOutput(); err != nil {
+				t.Log("TAR OUTPUT:", string(out))
+				t.Fatalf("packing '%s' with tar into '%s': %v", srcPath, dstPath, err)
+			}
+		case !packing && ops[i] == code:
+			// unpacking from code
+			if err := TarUnpack(srcPath, dstPath, true); err != nil {
+				t.Fatalf("unpacking '%s' with TarUnpack into '%s': %v", srcPath, dstPath, err)
+			}
+		case !packing && ops[i] == cli:
+			// unpacking from CLI
+			// tar requires destination directory to exist
+			if err := os.MkdirAll(dstPath, 0755); err != nil {
+				t.Fatalf("making dir '%s' for unpacking with tar: %v", dstPath, err)
+			}
+			if out, err := exec.Command("tar", "-xzvf", srcPath, "-C", dstPath).CombinedOutput(); err != nil {
+				t.Log("TAR OUTPUT:", string(out))
+				t.Fatalf("unpacking '%s' with tar into '%s': %v", srcPath, dstPath, err)
+			}
+		default:
+			t.Fatalf("unknown suffix: %s", string(ops[i]))
+		}
+
+		results[suffix] = dstPath
+	}
+}
+
+func assertUnpackedFilesEqual(t *testing.T, originalDir string, paths []string) {
+	originalDirHash, err := dirhash.HashDir(originalDir, "_", dirhash.DefaultHash)
+	if err != nil {
+		t.Fatal("failed hashing original dir ", err)
+	}
+
+	for _, p := range paths {
+		if strings.HasSuffix(p, archiveFileExt) {
+			// archive, not a directory
+			continue
+		}
+
+		// unpacked directory
+		hs, err := dirhash.HashDir(p, "_", dirhash.DefaultHash)
+		if err != nil {
+			t.Fatal("failed hashing dir ", err)
+		}
+
+		if hs != originalDirHash {
+			t.Errorf("expected '%s' to have the same hash as '%s', got different", originalDir, p)
+		}
+	}
+}
+
+func generateFileSystem(t *testing.T, inputPath string) {
+	// empty directory
+	if err := os.MkdirAll(filepath.Join(inputPath, "empty_dir"), 0755); err != nil {
+		t.Fatalf("generating empty directory: %v", err)
+	}
+
+	// deep empty directories
+	deepEmptyDirPath := filepath.Join(inputPath, "deep_empty_dir", strings.Repeat("/0/1/2", 20))
+	if err := os.MkdirAll(deepEmptyDirPath, 0755); err != nil {
+		t.Fatalf("generating deep empty directory '%s': %v", deepEmptyDirPath, err)
+	}
+
+	// empty file
+	f, err := os.Create(filepath.Join(inputPath, "empty_file"))
+	if err != nil {
+		t.Fatalf("generating empty file: %v", err)
+	}
+	f.Close()
+
+	// big (100MB) file
+	bigFilePath := filepath.Join(inputPath, "big_file")
+	for i := byte(0); i < 100; i++ {
+		// write 1MB
+		err := os.WriteFile(bigFilePath, bytes.Repeat([]byte{i}, 1024*1024), 0755)
+		if err != nil {
+			t.Fatalf("generating empty file: %v", err)
+		}
+	}
+}
+
+func TestUnpackZipSlip(t *testing.T) {
+	// Arrange: produce malicious archive
+	inputDir := t.TempDir()
+
+	const mContent = "malicious content"
+	const mFileName = "malicious.txt"
+	const mHeaderPath = "../" + mFileName // attack: path traversal
+	var mArchivePath = filepath.Join(inputDir, "malicious.tar.gz")
+
+	// temp file to pack
+	maliciousFile, err := os.Create(mArchivePath)
+	if err != nil {
+		t.Fatalf("failed to create temp file: %v", err)
+	}
+
+	gzWriter := gzip.NewWriter(maliciousFile)
+	tarWriter := tar.NewWriter(gzWriter)
+
+	// define a malicious file header
+	maliciousHeader := &tar.Header{
+		Name: mHeaderPath,
+		Size: int64(len(mContent)),
+		Mode: 0600,
+	}
+
+	err = tarWriter.WriteHeader(maliciousHeader)
+	if err != nil {
+		t.Fatalf("failed to write malicious header: %v", err)
+	}
+
+	// write malicious content
+	_, err = tarWriter.Write([]byte(mContent))
+	if err != nil {
+		t.Fatalf("failed to write content: %v", err)
+	}
+
+	err = errors.Join(tarWriter.Close(), gzWriter.Close(), maliciousFile.Close())
+	if err != nil {
+		t.Fatalf("failed to close writers: %v", err)
+	}
+
+	// Act & Assert: unpack nearby, expect error
+	var outputDir = filepath.Join(inputDir, "output")
+	if err := TarUnpack(mArchivePath, outputDir, true); err != nil {
+		if !errors.Is(err, tar.ErrInsecurePath) {
+			t.Fatalf("expected error tar.ErrInsecurePath, got: %v", err)
+		}
+	} else {
+		t.Error("unpack of malicious file succeeded, expected it to fail")
+	}
+
+	// Assert: check that file did not escape
+	var attackPath = filepath.Join(inputDir, mFileName)
+	if _, err := os.Stat(attackPath); err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			t.Fatalf("failed to check the existence of the malicious file: %v", err)
+		}
+	} else {
+		t.Errorf("malicious file escaped the destination: %s", attackPath)
+	}
+}
+
+func TestPackSameDir(t *testing.T) {
+	inputDir := t.TempDir()
+
+	err := TarPack(inputDir, filepath.Join(inputDir, "a.tar.gz"), false)
+
+	const expectedErr = "cannot be under source directory"
+	if err == nil {
+		t.Errorf("expected error '%s', got success", expectedErr)
+	} else if !strings.Contains(err.Error(), expectedErr) {
+		t.Errorf("expected error '%s', got: %v", expectedErr, err)
+	}
+}
+
+func TestSymlinks(t *testing.T) {
+	inputDir := t.TempDir()
+
+	testContent := []byte(time.Now().String())
+
+	testFileName := "d.txt"
+	testFilePath := filepath.Join(inputDir, testFileName)
+
+	if err := os.WriteFile(testFilePath, testContent, 0644); err != nil {
+		t.Fatalf("writing to %s: %v", testFilePath, err)
+	}
+
+	absSymlinkName := "abs_symlink_to_" + testFileName
+	absSymlinkPath := filepath.Join(inputDir, absSymlinkName)
+	if err := os.Symlink(testFilePath, absSymlinkPath); err != nil {
+		t.Fatalf("creating absolute symlink %s: %v", absSymlinkPath, err)
+	}
+
+	relSymlinkName := "rel_symlink_to_" + testFileName
+	relSymlinkPath := filepath.Join(inputDir, relSymlinkName)
+
+	relSymlinkTgt := "." + string(filepath.Separator) + testFileName
+	if err := os.Symlink(relSymlinkTgt, relSymlinkPath); err != nil {
+		t.Fatalf("creating relative symlink %s: %v", relSymlinkPath, err)
+	}
+
+	outputDir := t.TempDir()
+
+	archivePath := filepath.Join(outputDir, "output.tar.gz")
+	if err := TarPack(inputDir, archivePath, true); err != nil {
+		t.Fatalf("packing %s to %s: %v", inputDir, archivePath, err)
+	}
+
+	unpackedPath := filepath.Join(outputDir, "output")
+	if err := TarUnpack(archivePath, unpackedPath, true); err != nil {
+		t.Fatalf("unpacking %s to %s: %v", archivePath, unpackedPath, err)
+	}
+
+	// check absolute symlink
+	outputAbsSymlinkPath := filepath.Join(unpackedPath, absSymlinkName)
+	outputAbsSymlinkTgt, err := os.Readlink(outputAbsSymlinkPath)
+	if err != nil {
+		t.Fatalf("reading absolute link %s: %v", outputAbsSymlinkPath, err)
+	}
+	if outputAbsSymlinkTgt != testFilePath {
+		t.Errorf("expected absolute symlink to point to %s, got %s", testFilePath, outputAbsSymlinkTgt)
+	}
+	if data, err := os.ReadFile(outputAbsSymlinkPath); err != nil {
+		t.Fatalf("reading file %s: %v", outputAbsSymlinkPath, err)
+	} else if !bytes.Equal(testContent, data) {
+		t.Errorf("expected file %s to be: %X, got %X", outputAbsSymlinkPath, testContent, data)
+	}
+
+	// check relative symlink
+	outputRelSymlinkPath := filepath.Join(unpackedPath, relSymlinkName)
+	outputRelSymlinkTgt, err := os.Readlink(outputRelSymlinkPath)
+	if err != nil {
+		t.Fatalf("reading relative link %s: %v", outputRelSymlinkPath, err)
+	}
+	if outputRelSymlinkTgt != relSymlinkTgt {
+		t.Errorf("expected relative symlink to point to %s, got %s", relSymlinkTgt, outputRelSymlinkTgt)
+	}
+	if data, err := os.ReadFile(outputRelSymlinkPath); err != nil {
+		t.Fatalf("reading file %s: %v", outputRelSymlinkPath, err)
+	} else if !bytes.Equal(testContent, data) {
+		t.Errorf("expected file %s to be: %X, got %X", outputRelSymlinkPath, testContent, data)
+	}
+}