Repository: akuity/kargo

Issue:
Add untar promotion step
# Checklist

* [x] I've searched the issue queue to verify this is not a duplicate feature request.
* [ ] I've pasted the output of `kargo version`, if applicable.
* [ ] I've pasted logs, if applicable.

# Proposed Feature

A new untar promotion step for extracting tgz.

# Motivation

We heavily use helm charts, with umbrella charts and dependencies. But we want to keep the dependent charts as raw helm templates and not the tgz.
And the `helm dependency update` as no `--untar` flag see https://github.com/helm/helm/issues/8479

So we came with a plan for our promotion steps:
- helm-update-chart
- untar (the dependency)
- delete  (the tgz archive)

But obviously this can be use for any other extracting needs 😄 

# Suggested Implementation

I'll join a pull request for implementation...


Discussion:
@hiddeco: While I do believe a generic untar step may be beneficial in a context outside of Helm.

I do wonder how easy it will be to untar all dependencies, as you would have to be aware of their individual paths.@AlexDouze: As I put in the PR doc here is the flow i tested:

```yaml
vars:
- name: gitRepo
  value: https://github.com/example/repo.git
- name: chartRepo
  value: https://example.github.io/helm-charts
- name: chartName
  value: myChart
steps:
- uses: git-clone
  config:
    checkout:
      - branch: main
        path: ./target
    repoURL: ${{ vars.gitRepo }}
- uses: helm-update-chart
  config:
    path: ./target/pathToMyUmbrellaChart
    charts:
    - repository: ${{ vars.chartRepo }}
      name: ${{ vars.chartName }}
      version: ${{ chartFrom(vars.chartRepo, vars.chartName).Version }}
- as: deleteFormerChart
  uses: delete
  config:
    path: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}
- as: untar
  uses: untar
  config:
    filePath: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}-${{ chartFrom(vars.chartRepo, "kube-prometheus-stack").Version }}.tgz
    outPath: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}
- as: deleteTgz
  uses: delete
  config:
    path: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}-${{ chartFrom(vars.chartRepo, "kube-prometheus-stack").Version }}.tgz
# Then commit, push ...
```

But indeed, you'll have to run this 4 steps:
- helm-update-chart
- delete
- untar
- delete

For each of the dependencies in the umbrella chart, not optimal for sure but it does the job 😄 @krancour: Without regard to the specific use case, which I don't have too much of an opinion on, I fully support the notion of this being generically useful and a worthy addition. At a glance, #3927 mostly looks ok, but I'll leave a more exhaustive review to @hiddeco.

Solution patch:
diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/untar.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/untar.md
new file mode 100644
index 00000000..aad2ca85
--- /dev/null
+++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/untar.md
@@ -0,0 +1,102 @@
+---
+sidebar_label: untar
+description: Extracts the contents of a tar (or gzipped tar) file to a specified location.
+---
+
+# `untar`
+
+`untar` extracts the contents of a tar file (including gzipped tar files) to a specified location. It automatically detects if the file is gzipped and handles it accordingly.
+
+## Configuration
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `inPath` | `string` | Y | Path to the tar file to extract. This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. |
+| `outPath` | `string` | Y | Path to the destination directory where contents will be extracted. This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. The outPath content will be overriden if it already exists. |
+| `stripComponents` | `integer` | N | Number of leading path components to strip from file names in the archive. Similar to the `--strip-components` option in the tar command. |
+| `ignore` | `string` | N | A multiline string of glob patterns to ignore when extracting files. It accepts the same syntax as `.gitignore` files. |
+
+## Examples
+
+### Basic Usage
+
+Extract a tarball to a specific directory:
+
+```yaml
+steps:
+- uses: untar
+  config:
+    inPath: ./artifacts/bundle.tar.gz
+    outPath: ./extracted
+```
+
+### Strip Path Components
+
+Extract a tarball while removing leading directory components:
+
+```yaml
+steps:
+- uses: untar
+  config:
+    inPath: ./artifacts/bundle.tar.gz
+    outPath: ./extracted
+    stripComponents: 1
+```
+
+### Ignore Patterns
+
+Extract a tarball while ignoring specific files:
+
+```yaml
+steps:
+- uses: untar
+  config:
+    inPath: ./artifacts/bundle.tar.gz
+    outPath: ./extracted
+    ignore: |
+      *.log
+      temp/
+      .DS_Store
+```
+
+### Extract helm chart dependency
+
+This example shows how to update dependencies for a given helm chart and then extract the downloaded archive to have the raw manifests as dependencies instead of the tgz.
+
+```yaml
+vars:
+- name: gitRepo
+  value: https://github.com/example/repo.git
+- name: chartRepo
+  value: https://example.github.io/helm-charts
+- name: chartName
+  value: myChart
+steps:
+- uses: git-clone
+  config:
+    checkout:
+      - branch: main
+        path: ./target
+    repoURL: ${{ vars.gitRepo }}
+- uses: helm-update-chart
+  config:
+    path: ./target/pathToMyUmbrellaChart
+    charts:
+    - repository: ${{ vars.chartRepo }}
+      name: ${{ vars.chartName }}
+      version: ${{ chartFrom(vars.chartRepo, vars.chartName).Version }}
+- as: deleteFormerChart
+  uses: delete
+  config:
+    path: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}
+- as: untar
+  uses: untar
+  config:
+    inPath: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}-${{ chartFrom(vars.chartRepo, "kube-prometheus-stack").Version }}.tgz
+    outPath: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}
+- as: deleteTgz
+  uses: delete
+  config:
+    path: ./target/pathToMyUmbrellaChart/charts/${{ vars.chartName }}-${{ chartFrom(vars.chartRepo, "kube-prometheus-stack").Version }}.tgz
+# Then commit, push ...
+```
diff --git a/internal/promotion/runner/builtin/init.go b/internal/promotion/runner/builtin/init.go
index 0b36660c..8fd31f80 100644
--- a/internal/promotion/runner/builtin/init.go
+++ b/internal/promotion/runner/builtin/init.go
@@ -44,6 +44,7 @@ func Initialize(kargoClient, argocdClient client.Client, credsDB credentials.Dat
 		pkgPromotion.NewTaskLevelOutputStepRunner(
 			newOutputComposer(),
 		),
+		newTarExtractor(),
 		newYAMLParser(),
 		newYAMLUpdater(),
 	}
diff --git a/internal/promotion/runner/builtin/schemas/untar-config.json b/internal/promotion/runner/builtin/schemas/untar-config.json
new file mode 100644
index 00000000..fedaff65
--- /dev/null
+++ b/internal/promotion/runner/builtin/schemas/untar-config.json
@@ -0,0 +1,29 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "UntarConfig",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["inPath", "outPath"],
+  "properties": {
+    "inPath": {
+      "type": "string",
+      "description": "InPath is the path to the tar file to extract.",
+      "minLength": 1
+    },
+    "outPath": {
+      "type": "string",
+      "description": "OutPath is the path to the destination directory where contents will be extracted. The outPath content will be overriden if it already exists.",
+      "minLength": 1
+    },
+    "stripComponents": {
+      "type": "integer",
+      "description": "StripComponents is the number of leading components to strip from file names in the archive.",
+      "minimum": 0
+    },
+    "ignore": {
+      "type": "string",
+      "description": "Ignore is a (multiline) string of glob patterns to ignore when extracting files. It accepts the same syntax as .gitignore files.",
+      "minLength": 1
+    }
+  }
+}
diff --git a/internal/promotion/runner/builtin/tar_extractor.go b/internal/promotion/runner/builtin/tar_extractor.go
new file mode 100644
index 00000000..5a62897d
--- /dev/null
+++ b/internal/promotion/runner/builtin/tar_extractor.go
@@ -0,0 +1,413 @@
+package builtin
+
+import (
+	"archive/tar"
+	"bufio"
+	"compress/gzip"
+	"context"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+
+	securejoin "github.com/cyphar/filepath-securejoin"
+	"github.com/go-git/go-git/v5/plumbing/format/gitignore"
+	"github.com/xeipuuv/gojsonschema"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/logging"
+	"github.com/akuity/kargo/pkg/promotion"
+	"github.com/akuity/kargo/pkg/x/promotion/runner/builtin"
+)
+
+// Size limits to prevent decompression bombs
+const (
+	// MaxDecompressedTarSize is the maximum size of all files extracted from a
+	// tar archive.
+	MaxDecompressedTarSize int64 = 100 * 1024 * 1024
+	// MaxDecompressedFileSize is the maximum size of a single file extracted
+	// from a tar archive.
+	MaxDecompressedFileSize int64 = 50 * 1024 * 1024
+
+	// gzipID1 is the first byte of the gzip magic number
+	gzipID1 = 0x1F
+	// gzipID2 is the second byte of the gzip magic number
+	gzipID2 = 0x8B
+
+	// defaultDirPermissions is the default permissions for directories created
+	// by the tar extractor.
+	defaultDirPermissions = 0o750
+)
+
+// tarExtractor is an implementation of the promotion.StepRunner interface that
+// extracts a tar file to a specified directory.
+type tarExtractor struct {
+	schemaLoader gojsonschema.JSONLoader
+}
+
+// newTarExtractor returns an implementation of the promotion.StepRunner
+// interface that extracts a tar file.
+func newTarExtractor() promotion.StepRunner {
+	r := &tarExtractor{}
+	r.schemaLoader = getConfigSchemaLoader(r.Name())
+	return r
+}
+
+// Name implements the promotion.StepRunner interface.
+func (t *tarExtractor) Name() string {
+	return "untar"
+}
+
+// Run implements the promotion.StepRunner interface.
+func (t *tarExtractor) Run(
+	ctx context.Context,
+	stepCtx *promotion.StepContext,
+) (promotion.StepResult, error) {
+	// Validate the configuration against the JSON Schema.
+	if err := validate(t.schemaLoader, gojsonschema.NewGoLoader(stepCtx.Config), t.Name()); err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored}, err
+	}
+
+	// Convert the configuration into a typed object.
+	cfg, err := promotion.ConfigToStruct[builtin.UntarConfig](stepCtx.Config)
+	if err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("could not convert config into %s config: %w", t.Name(), err)
+	}
+
+	return t.run(ctx, stepCtx, cfg)
+}
+
+func (t *tarExtractor) run(
+	ctx context.Context,
+	stepCtx *promotion.StepContext,
+	cfg builtin.UntarConfig,
+) (promotion.StepResult, error) {
+	logger := logging.LoggerFromContext(ctx)
+
+	// Secure join the paths to prevent path traversal attacks
+	inPath, err := securejoin.SecureJoin(stepCtx.WorkDir, cfg.InPath)
+	if err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("could not secure join inPath %q: %w", cfg.InPath, err)
+	}
+	outPath, err := securejoin.SecureJoin(stepCtx.WorkDir, cfg.OutPath)
+	if err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("could not secure join outPath %q: %w", cfg.OutPath, err)
+	}
+
+	// Create a temporary directory to atomically extract the tar file
+	tempDir, err := os.MkdirTemp(stepCtx.WorkDir, "." + t.Name() + "-*")
+	if err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("failed to create temporary directory for extraction: %w", err)
+	}
+
+	// Ensure the temporary directory is cleaned up after extraction
+	defer func() {
+		if err = os.RemoveAll(tempDir); err != nil {
+			logger.Error(err, "failed to remove temporary directory after extraction")
+		}
+	}()
+
+	// Extract the tar file to the temporary directory
+	result, err := t.extractToDir(ctx, cfg, inPath, tempDir)
+	if err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("failed to extract tar file %q: %w", inPath, err)
+	}
+
+	// Move the extracted files from the temporary directory to the final output
+	// path atomically
+	if err = t.simpleAtomicMove(tempDir, outPath); err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored}, err
+	}
+
+	// Return the result of the extraction
+	return result, nil
+}
+
+func (t *tarExtractor) extractToDir(
+	ctx context.Context,
+	cfg builtin.UntarConfig,
+	inPath, outPath string,
+) (promotion.StepResult, error) {
+	// Load the ignore rules.
+	matcher, err := t.loadIgnoreRules(outPath, cfg.Ignore)
+	if err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("failed to load ignore rules: %w", err)
+	}
+
+	// Open the tar file
+	file, err := os.Open(inPath)
+	if err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("failed to open tar file %q: %w", cfg.InPath, err)
+	}
+	defer file.Close()
+
+	logger := logging.LoggerFromContext(ctx)
+	var tarReader *tar.Reader
+
+	// Read the first few bytes to check magic numbers
+	header := make([]byte, 2)
+	if _, err = file.Read(header); err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("failed to read file header: %w", err)
+	}
+
+	// Reset to the beginning of the file
+	if _, err = file.Seek(0, 0); err != nil {
+		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+			fmt.Errorf("failed to seek in tar file: %w", err)
+	}
+
+	// Check for gzip magic numbers
+	if header[0] == gzipID1 && header[1] == gzipID2 {
+		// File is gzipped
+		gzr, err := gzip.NewReader(file)
+		if err != nil {
+			return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+				fmt.Errorf("failed to create gzip reader: %w", err)
+		}
+		defer gzr.Close()
+		tarReader = tar.NewReader(gzr)
+		logger.Trace("treating file as gzipped tar based on magic numbers")
+	} else {
+		// File is not gzipped
+		tarReader = tar.NewReader(file)
+		logger.Trace("treating file as regular tar")
+	}
+
+	// Extract the tar file
+	stripComponents := int64(0)
+	if cfg.StripComponents != nil {
+		stripComponents = *cfg.StripComponents
+		if stripComponents > 0 {
+			logger.Trace("stripping path components", "count", stripComponents)
+		}
+	}
+
+	// Track the total size extracted
+	var totalExtractedSize int64
+
+	// Track directories created to avoid duplicates
+	madeDir := make(map[string]bool)
+
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break // End of archive
+		}
+		if err != nil {
+			return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+				fmt.Errorf("error reading tar: %w", err)
+		}
+
+		// Skip if this is not a regular file, symlink, or directory
+		if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir && header.Typeflag != tar.TypeSymlink {
+			logger.Trace("skipping non-regular file", "path", header.Name, "type", header.Typeflag)
+			continue
+		}
+
+		// Handle stripping components if specified
+		targetName := header.Name
+		if stripComponents > 0 {
+			// Get parts of the path
+			parts := strings.Split(header.Name, "/")
+			// Only strip if we have enough components
+			if len(parts) > int(stripComponents) {
+				targetName = strings.Join(parts[stripComponents:], "/")
+			} else {
+				// Skip this file if we don't have enough components
+				logger.Trace("skipping file with insufficient path components", "path", header.Name)
+				continue
+			}
+		}
+
+		// Skip any empty targetName which can happen if we're processing a directory entry
+		if targetName == "" || targetName == "/" {
+			continue
+		}
+
+		// Validate the target path to prevent directory traversal
+		if !t.validRelPath(targetName) {
+			logger.Trace("skipping file with unsafe path", "path", targetName)
+			continue
+		}
+
+		// Simple check for exact filename match for ignore patterns
+		if cfg.Ignore != "" && filepath.Base(targetName) == strings.TrimSpace(cfg.Ignore) {
+			logger.Trace("ignoring exact match path", "path", targetName)
+			continue
+		}
+
+		// Check more complex patterns using gitignore matcher
+		isDir := header.Typeflag == tar.TypeDir
+		pathParts := strings.Split(targetName, "/")
+		if matcher.Match(pathParts, isDir) {
+			logger.Trace("ignoring path based on pattern", "path", targetName)
+			continue
+		}
+
+		// Create the destination directory for files and links
+		targetPath := filepath.Join(outPath, targetName)
+
+		// Double-check the target path is within the output directory
+		relPath, err := filepath.Rel(outPath, targetPath)
+		if err != nil || strings.HasPrefix(relPath, "..") || strings.HasPrefix(relPath, "/") {
+			logger.Trace("skipping path escaping target directory", "path", targetName)
+			continue
+		}
+
+		destDir := filepath.Dir(targetPath)
+		if err = os.MkdirAll(destDir, defaultDirPermissions); err != nil {
+			return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+				fmt.Errorf("failed to create directory %s: %w", destDir, err)
+		}
+
+		switch header.Typeflag {
+		case tar.TypeDir:
+			// Create directory
+			if err = os.MkdirAll(targetPath, defaultDirPermissions); err != nil {
+				return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+					fmt.Errorf("failed to create directory %s: %w", targetPath, err)
+			}
+			madeDir[targetPath] = true
+
+		case tar.TypeSymlink:
+			// Validate the symlink target to prevent symlink attacks
+			if !t.validRelPath(header.Linkname) {
+				logger.Trace("skipping symlink with unsafe target", "path", targetName, "target", header.Linkname)
+				continue
+			}
+			// Create symlink
+			if err := os.Symlink(header.Linkname, targetPath); err != nil && !os.IsExist(err) {
+				logger.Error(err, "failed to create symlink", "path", targetPath, "target", header.Linkname)
+			}
+		case tar.TypeReg:
+			// Check if single file exceeds max size limit
+			logger.Trace("checking file size", "path", targetName, "size", header.Size)
+			if header.Size > MaxDecompressedFileSize {
+				logger.Trace("aborting extraction due to exceeding file size limit",
+					"path", targetName,
+					"size", header.Size,
+					"limit", MaxDecompressedFileSize)
+				return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+					fmt.Errorf("extraction aborted: file %s exceeds size limit of %d bytes", targetName, MaxDecompressedFileSize)
+			}
+
+			// Check if total extracted size would exceed limit
+			if totalExtractedSize+header.Size > MaxDecompressedTarSize {
+				logger.Trace("aborting extraction due to exceeding total size limit",
+					"totalSize", totalExtractedSize,
+					"fileSize", header.Size,
+					"limit", MaxDecompressedTarSize)
+				return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+					fmt.Errorf("extraction aborted: total size would exceed limit of %d bytes", MaxDecompressedTarSize)
+			}
+
+			dir := filepath.Dir(targetPath)
+			// Create the directory if it doesn't exist
+			if !madeDir[dir] {
+				if err = os.MkdirAll(dir, defaultDirPermissions); err != nil {
+					return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+						fmt.Errorf("failed to create directory %s: %w", dir, err)
+				}
+				madeDir[dir] = true
+			}
+
+			// Create a file
+			safeMode := fs.FileMode(header.Mode) & 0o777
+			outFile, err := os.OpenFile(targetPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, safeMode)
+			if err != nil {
+				return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+					fmt.Errorf("failed to create file %s: %w", targetPath, err)
+			}
+
+			// Limit copying to the declared size
+			written, err := io.CopyN(outFile, tarReader, header.Size)
+			outFile.Close()
+			if err != nil && err != io.EOF {
+				return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+					fmt.Errorf("failed to write to file %s: %w", targetPath, err)
+			}
+
+			// Update total size counter
+			totalExtractedSize += written
+
+			// Change mod time
+			if !header.ModTime.IsZero() {
+				if err := os.Chtimes(targetPath, header.ModTime, header.ModTime); err != nil {
+					return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
+						fmt.Errorf("failed to change mod time for %s: %w", targetPath, err)
+				}
+			}
+		}
+	}
+
+	return promotion.StepResult{Status: kargoapi.PromotionStepStatusSucceeded}, nil
+}
+
+// validRelPath checks if the path is safe to extract (no path traversal, etc.)
+func (t *tarExtractor) validRelPath(p string) bool {
+	if p == "" ||
+		strings.Contains(p, `\`) ||
+		strings.HasPrefix(p, "/") ||
+		strings.Contains(p, "../") ||
+		strings.HasPrefix(p, "./") {
+		return false
+	}
+	return true
+}
+
+// loadIgnoreRules loads the ignore rules from the given string. The rules are
+// separated by newlines, and comments are allowed with the '#' character.
+// It returns a gitignore.Matcher that can be used to match paths against the
+// rules.
+func (t *tarExtractor) loadIgnoreRules(outPath, rules string) (gitignore.Matcher, error) {
+	// Determine the domain for the ignore rules
+	domain := strings.Split(outPath, string(filepath.Separator))
+
+	// Create patterns from the provided rules
+	var ps []gitignore.Pattern
+	if rules != "" {
+		scanner := bufio.NewScanner(strings.NewReader(rules))
+		for scanner.Scan() {
+			s := scanner.Text()
+			if !strings.HasPrefix(s, "#") && len(strings.TrimSpace(s)) > 0 {
+				ps = append(ps, gitignore.ParsePattern(s, domain))
+			}
+		}
+	}
+
+	// If no patterns were provided, add a default pattern to ignore any .git/
+	// directory
+	if len(ps) == 0 {
+		ps = append(ps, gitignore.ParsePattern(".git", domain))
+	}
+
+	return gitignore.NewMatcher(ps), nil
+}
+
+func (t *tarExtractor) simpleAtomicMove(src, dst string) error {
+	if err := os.Rename(src, dst); err != nil {
+		if !os.IsExist(err) {
+			return fmt.Errorf("failed to move %s to %s: %w", src, dst, err)
+		}
+
+		// If the destination already exists, remove it and try again
+		if err := os.RemoveAll(dst); err != nil {
+			return fmt.Errorf("failed to remove existing destination %s: %w", dst, err)
+		}
+
+		if err := os.Rename(src, dst); err != nil {
+			return fmt.Errorf("failed to move %s to %s after removing existing: %w", src, dst, err)
+		}
+	}
+	return nil
+}
diff --git a/pkg/x/promotion/runner/builtin/zz_config_types.go b/pkg/x/promotion/runner/builtin/zz_config_types.go
index 880c8e61..8d9973a3 100644
--- a/pkg/x/promotion/runner/builtin/zz_config_types.go
+++ b/pkg/x/promotion/runner/builtin/zz_config_types.go
@@ -413,6 +413,20 @@ type Image struct {
 	Tag string `json:"tag,omitempty"`
 }
 
+type UntarConfig struct {
+	// Ignore is a (multiline) string of glob patterns to ignore when extracting files. It
+	// accepts the same syntax as .gitignore files.
+	Ignore string `json:"ignore,omitempty"`
+	// InPath is the path to the tar file to extract.
+	InPath string `json:"inPath"`
+	// OutPath is the path to the destination directory where contents will be extracted. The
+	// outPath content will be overriden if it already exists.
+	OutPath string `json:"outPath"`
+	// StripComponents is the number of leading components to strip from file names in the
+	// archive.
+	StripComponents *int64 `json:"stripComponents,omitempty"`
+}
+
 type YAMLParseConfig struct {
 	// An array of outputs to extract from the YAML file.
 	Outputs []YAMLParse `json:"outputs"`
diff --git a/ui/src/gen/directives/untar-config.json b/ui/src/gen/directives/untar-config.json
new file mode 100644
index 00000000..9102e61d
--- /dev/null
+++ b/ui/src/gen/directives/untar-config.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "UntarConfig",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+  "inPath": {
+   "type": "string",
+   "description": "InPath is the path to the tar file to extract.",
+   "minLength": 1
+  },
+  "outPath": {
+   "type": "string",
+   "description": "OutPath is the path to the destination directory where contents will be extracted. The outPath content will be overriden if it already exists.",
+   "minLength": 1
+  },
+  "stripComponents": {
+   "type": "integer",
+   "description": "StripComponents is the number of leading components to strip from file names in the archive.",
+   "minimum": 0
+  },
+  "ignore": {
+   "type": "string",
+   "description": "Ignore is a (multiline) string of glob patterns to ignore when extracting files. It accepts the same syntax as .gitignore files.",
+   "minLength": 1
+  }
+ }
+}
\ No newline at end of file


Test patch:
diff --git a/internal/promotion/runner/builtin/tar_extractor_test.go b/internal/promotion/runner/builtin/tar_extractor_test.go
new file mode 100644
index 00000000..caa66e7d
--- /dev/null
+++ b/internal/promotion/runner/builtin/tar_extractor_test.go
@@ -0,0 +1,944 @@
+package builtin
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"k8s.io/utils/ptr"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/pkg/promotion"
+	"github.com/akuity/kargo/pkg/x/promotion/runner/builtin"
+)
+
+func Test_tarExtractor_run(t *testing.T) {
+	tests := []struct {
+		name       string
+		setupFiles func(*testing.T) string
+		cfg        builtin.UntarConfig
+		assertions func(*testing.T, string, promotion.StepResult, error)
+	}{
+		{
+			name: "succeeds with basic extraction and atomic behavior",
+			setupFiles: func(t *testing.T) string {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "archive.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add a simple file
+				data := []byte("test content")
+				hdr := &tar.Header{
+					Name: "file.txt",
+					Mode: 0o600,
+					Size: int64(len(data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(data)
+				require.NoError(t, err)
+
+				return tmpDir
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "archive.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				// Verify extraction worked
+				extractDir := filepath.Join(workDir, "extracted")
+				filePath := filepath.Join(extractDir, "file.txt")
+				content, err := os.ReadFile(filePath)
+				assert.NoError(t, err)
+				assert.Equal(t, "test content", string(content))
+
+				// Verify no temp directories remain
+				entries, err := os.ReadDir(workDir)
+				assert.NoError(t, err)
+				for _, entry := range entries {
+					assert.False(t, entry.IsDir() && entry.Name()[0] == '.' &&
+						len(entry.Name()) > 6 && entry.Name()[1:6] == "untar",
+						"No temporary directories should remain: %s", entry.Name())
+				}
+			},
+		},
+		{
+			name: "atomically replaces existing destination",
+			setupFiles: func(t *testing.T) string {
+				tmpDir := t.TempDir()
+
+				// Create existing destination with old content
+				extractDir := filepath.Join(tmpDir, "extracted")
+				require.NoError(t, os.MkdirAll(extractDir, 0o755))
+				require.NoError(t, os.WriteFile(filepath.Join(extractDir, "old_file.txt"), []byte("old content"), 0o644))
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "archive.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add a new file to the tar
+				newData := []byte("new content")
+				hdr := &tar.Header{
+					Name: "new_file.txt",
+					Mode: 0o600,
+					Size: int64(len(newData)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(newData)
+				require.NoError(t, err)
+
+				return tmpDir
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "archive.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				extractDir := filepath.Join(workDir, "extracted")
+
+				// New file should exist
+				newFilePath := filepath.Join(extractDir, "new_file.txt")
+				b, err := os.ReadFile(newFilePath)
+				assert.NoError(t, err)
+				assert.Equal(t, "new content", string(b))
+
+				// Old file should be gone (atomically replaced)
+				oldFilePath := filepath.Join(extractDir, "old_file.txt")
+				_, err = os.Stat(oldFilePath)
+				assert.True(t, os.IsNotExist(err), "Old file should be replaced")
+			},
+		},
+		{
+			name: "fails with invalid input file path and maintains atomicity",
+			setupFiles: func(t *testing.T) string {
+				return t.TempDir()
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "nonexistent.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir string, result promotion.StepResult, err error) {
+				assert.Equal(t, kargoapi.PromotionStepStatusErrored, result.Status)
+				assert.ErrorContains(t, err, "failed to open tar file")
+
+				// Verify no extraction directory was created due to atomic behavior
+				extractDir := filepath.Join(workDir, "extracted")
+				_, err = os.Stat(extractDir)
+				assert.True(t, os.IsNotExist(err), "No extraction directory should exist after failure")
+
+				// Verify no temp directories remain
+				entries, err := os.ReadDir(workDir)
+				assert.NoError(t, err)
+				for _, entry := range entries {
+					assert.False(t, entry.IsDir() && entry.Name()[0] == '.' &&
+						len(entry.Name()) > 6 && entry.Name()[1:6] == "untar",
+						"No temporary directories should remain: %s", entry.Name())
+				}
+			},
+		},
+		{
+			name: "fails during extraction and maintains atomicity",
+			setupFiles: func(t *testing.T) string {
+				tmpDir := t.TempDir()
+
+				// Create a tar file with a file that exceeds size limits
+				tarPath := filepath.Join(tmpDir, "oversized.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add a file that exceeds the size limit
+				largeFileSize := MaxDecompressedFileSize + 1
+				hdr := &tar.Header{
+					Name: "large_file.bin",
+					Mode: 0o600,
+					Size: largeFileSize,
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(make([]byte, largeFileSize))
+				require.NoError(t, err)
+
+				return tmpDir
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "oversized.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir string, result promotion.StepResult, err error) {
+				assert.Error(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusErrored, result.Status)
+				assert.ErrorContains(t, err, "exceeds size limit")
+
+				// Ensure no partial files remain due to atomic extraction
+				extractDir := filepath.Join(workDir, "extracted")
+				_, err = os.Stat(extractDir)
+				assert.True(t, os.IsNotExist(err))
+
+				// Verify no temp directories remain
+				entries, err := os.ReadDir(workDir)
+				assert.NoError(t, err)
+				for _, entry := range entries {
+					assert.False(t, entry.IsDir() && entry.Name()[0] == '.' &&
+						len(entry.Name()) > 6 && entry.Name()[1:6] == "untar",
+						"no temporary directories should remain: %s", entry.Name())
+				}
+			},
+		},
+	}
+
+	runner := &tarExtractor{}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			workDir := tt.setupFiles(t)
+			result, err := runner.run(
+				context.Background(),
+				&promotion.StepContext{WorkDir: workDir},
+				tt.cfg,
+			)
+			tt.assertions(t, workDir, result, err)
+		})
+	}
+}
+
+func Test_tarExtractor_extractToDir(t *testing.T) {
+	tests := []struct {
+		name       string
+		setupFiles func(*testing.T) (string, string) // returns workDir, tarPath
+		cfg        builtin.UntarConfig
+		assertions func(*testing.T, string, string, promotion.StepResult, error) // workDir, extractDir, result, err
+	}{
+		{
+			name: "succeeds extracting simple tar file",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "archive.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add a file to the tar
+				file1Data := []byte("test content")
+				hdr := &tar.Header{
+					Name: "file1.txt",
+					Mode: 0o600,
+					Size: int64(len(file1Data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(file1Data)
+				require.NoError(t, err)
+
+				// Add a directory to the tar
+				hdr = &tar.Header{
+					Name:     "testdir/",
+					Mode:     0o755,
+					Typeflag: tar.TypeDir,
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+
+				// Add a file in the directory
+				file2Data := []byte("nested content")
+				hdr = &tar.Header{
+					Name: "testdir/file2.txt",
+					Mode: 0o600,
+					Size: int64(len(file2Data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(file2Data)
+				require.NoError(t, err)
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "archive.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				// Check the root file
+				file1Path := filepath.Join(extractDir, "file1.txt")
+				b, err := os.ReadFile(file1Path)
+				assert.NoError(t, err)
+				assert.Equal(t, "test content", string(b))
+
+				// Check the directory structure
+				testDirPath := filepath.Join(extractDir, "testdir")
+				assert.DirExists(t, testDirPath)
+
+				// Check the nested file
+				file2Path := filepath.Join(testDirPath, "file2.txt")
+				b, err = os.ReadFile(file2Path)
+				assert.NoError(t, err)
+				assert.Equal(t, "nested content", string(b))
+			},
+		},
+		{
+			name: "succeeds extracting gzipped tar file",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar.gz file
+				tarPath := filepath.Join(tmpDir, "archive.tar.gz")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				gzw := gzip.NewWriter(tarFile)
+				defer gzw.Close()
+
+				tw := tar.NewWriter(gzw)
+				defer tw.Close()
+
+				// Add a file to the tar
+				file1Data := []byte("compressed content")
+				hdr := &tar.Header{
+					Name: "compressed.txt",
+					Mode: 0o600,
+					Size: int64(len(file1Data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(file1Data)
+				require.NoError(t, err)
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "archive.tar.gz",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				filePath := filepath.Join(extractDir, "compressed.txt")
+				b, err := os.ReadFile(filePath)
+				assert.NoError(t, err)
+				assert.Equal(t, "compressed content", string(b))
+			},
+		},
+		{
+			name: "strip components",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "archive.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add files with nested paths
+				data := []byte("test content")
+				hdr := &tar.Header{
+					Name: "prefix1/prefix2/file.txt",
+					Mode: 0o600,
+					Size: int64(len(data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(data)
+				require.NoError(t, err)
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:          "archive.tar",
+				OutPath:         "extracted/",
+				StripComponents: ptr.To(int64(2)),
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				filePath := filepath.Join(extractDir, "file.txt")
+				b, err := os.ReadFile(filePath)
+				assert.NoError(t, err)
+				assert.Equal(t, "test content", string(b))
+
+				// Ensure prefix directories were stripped
+				assert.NoDirExists(t, filepath.Join(extractDir, "prefix1"))
+			},
+		},
+		{
+			name: "ignore rules",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "archive.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add files
+				file1Data := []byte("include me")
+				hdr := &tar.Header{
+					Name: "include.txt",
+					Mode: 0o600,
+					Size: int64(len(file1Data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(file1Data)
+				require.NoError(t, err)
+
+				file2Data := []byte("ignore me")
+				hdr = &tar.Header{
+					Name: "ignore.txt",
+					Mode: 0o600,
+					Size: int64(len(file2Data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(file2Data)
+				require.NoError(t, err)
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "archive.tar",
+				OutPath: "extracted/",
+				Ignore:  "ignore.txt",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				// Check included file
+				includePath := filepath.Join(extractDir, "include.txt")
+				b, err := os.ReadFile(includePath)
+				assert.NoError(t, err)
+				assert.Equal(t, "include me", string(b))
+
+				// Check ignored file
+				ignorePath := filepath.Join(extractDir, "ignore.txt")
+				_, err = os.Stat(ignorePath)
+				assert.True(t, os.IsNotExist(err))
+			},
+		},
+		{
+			name: "handles symbolic links",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "archive.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add a file
+				fileData := []byte("target content")
+				hdr := &tar.Header{
+					Name: "target.txt",
+					Mode: 0o600,
+					Size: int64(len(fileData)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(fileData)
+				require.NoError(t, err)
+
+				// Add a symlink
+				hdr = &tar.Header{
+					Name:     "link.txt",
+					Linkname: "target.txt",
+					Typeflag: tar.TypeSymlink,
+					Mode:     0o777,
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "archive.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				// Check the regular file
+				targetPath := filepath.Join(extractDir, "target.txt")
+				b, err := os.ReadFile(targetPath)
+				assert.NoError(t, err)
+				assert.Equal(t, "target content", string(b))
+
+				// Check the symlink
+				linkPath := filepath.Join(extractDir, "link.txt")
+				linkTarget, err := os.Readlink(linkPath)
+				assert.NoError(t, err)
+				assert.Equal(t, "target.txt", linkTarget)
+
+				// Verify symlink works
+				b, err = os.ReadFile(linkPath)
+				assert.NoError(t, err)
+				assert.Equal(t, "target content", string(b))
+			},
+		},
+		{
+			name: "fails with non-tar file",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+				badPath := filepath.Join(tmpDir, "notatar.txt")
+				require.NoError(t, os.WriteFile(badPath, []byte("not a tar file"), 0o600))
+				return tmpDir, badPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "notatar.txt",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.Equal(t, kargoapi.PromotionStepStatusErrored, result.Status)
+				assert.ErrorContains(t, err, "error reading tar")
+			},
+		},
+		{
+			name: "skips unsafe path traversal attempts",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file with unsafe paths
+				tarPath := filepath.Join(tmpDir, "unsafe.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add files with unsafe paths
+				unsafePaths := []string{
+					"../outside.txt",           // Path traversal
+					"/etc/passwd",              // Absolute path
+					"subdir/../../outside.txt", // Path traversal
+					"./config.txt",             // Relative current directory
+					"safe.txt",                 // Safe path (should be extracted)
+				}
+
+				for _, path := range unsafePaths {
+					data := []byte("test content for " + path)
+					hdr := &tar.Header{
+						Name: path,
+						Mode: 0o600,
+						Size: int64(len(data)),
+					}
+					require.NoError(t, tw.WriteHeader(hdr))
+					_, err = tw.Write(data)
+					require.NoError(t, err)
+				}
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "unsafe.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				// Only the safe file should exist
+				safePath := filepath.Join(extractDir, "safe.txt")
+				_, err = os.Stat(safePath)
+				assert.NoError(t, err, "Safe file should be extracted")
+
+				// These unsafe paths should not exist relative to the extract dir
+				unsafePaths := []string{
+					filepath.Join(workDir, "outside.txt"),      // Path traversal
+					filepath.Join(extractDir, "etc", "passwd"), // Absolute path
+					filepath.Join(workDir, "outside.txt"),      // Path traversal
+					filepath.Join(extractDir, "config.txt"),    // Relative current directory
+				}
+
+				for _, path := range unsafePaths {
+					_, err = os.Stat(path)
+					assert.True(t, os.IsNotExist(err), fmt.Sprintf("Unsafe path should not be extracted: %s", path))
+				}
+			},
+		},
+		{
+			name: "skips unsafe symlinks",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file with unsafe symlinks
+				tarPath := filepath.Join(tmpDir, "unsafe_symlinks.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add a valid file
+				fileData := []byte("target content")
+				hdr := &tar.Header{
+					Name: "safe.txt",
+					Mode: 0o600,
+					Size: int64(len(fileData)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(fileData)
+				require.NoError(t, err)
+
+				// Add symlinks with unsafe targets
+				symlinks := map[string]string{
+					"safe_link.txt":    "safe.txt",            // Safe symlink
+					"unsafe_link1.txt": "../outside.txt",      // Path traversal
+					"unsafe_link2.txt": "/etc/passwd",         // Absolute path
+					"unsafe_link3.txt": "./unsafe_target.txt", // Starts with ./
+				}
+
+				for name, target := range symlinks {
+					hdr = &tar.Header{
+						Name:     name,
+						Linkname: target,
+						Typeflag: tar.TypeSymlink,
+						Mode:     0o777,
+					}
+					require.NoError(t, tw.WriteHeader(hdr))
+				}
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "unsafe_symlinks.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				// The safe file and symlink should exist
+				safePath := filepath.Join(extractDir, "safe.txt")
+				_, err = os.Stat(safePath)
+				assert.NoError(t, err, "Safe file should be extracted")
+
+				safeLinkPath := filepath.Join(extractDir, "safe_link.txt")
+				_, err = os.Stat(safeLinkPath)
+				assert.NoError(t, err, "Safe symlink should be extracted")
+
+				// Unsafe symlinks should not be created
+				unsafeLinks := []string{
+					filepath.Join(extractDir, "unsafe_link1.txt"),
+					filepath.Join(extractDir, "unsafe_link2.txt"),
+					filepath.Join(extractDir, "unsafe_link3.txt"),
+				}
+
+				for _, path := range unsafeLinks {
+					_, err = os.Stat(path)
+					assert.True(t, os.IsNotExist(err), fmt.Sprintf("Unsafe symlink should not be extracted: %s", path))
+				}
+			},
+		},
+		{
+			name: "fails with file larger than size limit",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "oversized.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Mock large file that exceeds the size limit
+				largeFileSize := MaxDecompressedFileSize + 1
+				hdr := &tar.Header{
+					Name: "large_file.bin",
+					Mode: 0o600,
+					Size: largeFileSize,
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+
+				// Write zeros to the file
+				_, err = tw.Write(make([]byte, largeFileSize))
+				require.NoError(t, err)
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "oversized.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.Error(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusErrored, result.Status)
+				assert.ErrorContains(t, err, "exceeds size limit")
+				assert.ErrorContains(t, err, "large_file.bin")
+			},
+		},
+		{
+			name: "fails with total archive size larger than limit",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "oversized.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				nbFiles := (MaxDecompressedTarSize / MaxDecompressedFileSize) + 1
+				for i := range int(nbFiles) {
+					hdr := &tar.Header{
+						Name: fmt.Sprintf("file%d.bin", i),
+						Mode: 0o600,
+						Size: MaxDecompressedFileSize - 1,
+					}
+					require.NoError(t, tw.WriteHeader(hdr))
+
+					// Write zeros to the file
+					_, err = tw.Write(make([]byte, MaxDecompressedFileSize-1))
+					require.NoError(t, err)
+				}
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "oversized.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.Error(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusErrored, result.Status)
+				assert.ErrorContains(t, err, "extraction aborted: total size would exceed limit")
+			},
+		},
+		{
+			name: "handles file permissions safely (masks setuid/setgid)",
+			setupFiles: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+
+				// Create a tar file
+				tarPath := filepath.Join(tmpDir, "permissions.tar")
+				tarFile, err := os.Create(tarPath)
+				require.NoError(t, err)
+				defer tarFile.Close()
+
+				tw := tar.NewWriter(tarFile)
+				defer tw.Close()
+
+				// Add a file with setuid bit set (should be masked)
+				data := []byte("test content")
+				hdr := &tar.Header{
+					Name: "setuid_file.txt",
+					Mode: 0o4755, // setuid + rwxr-xr-x
+					Size: int64(len(data)),
+				}
+				require.NoError(t, tw.WriteHeader(hdr))
+				_, err = tw.Write(data)
+				require.NoError(t, err)
+
+				return tmpDir, tarPath
+			},
+			cfg: builtin.UntarConfig{
+				InPath:  "permissions.tar",
+				OutPath: "extracted/",
+			},
+			assertions: func(t *testing.T, workDir, extractDir string, result promotion.StepResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionStepStatusSucceeded, result.Status)
+
+				// Check that file was created with safe permissions (setuid bit removed)
+				filePath := filepath.Join(extractDir, "setuid_file.txt")
+				info, err := os.Stat(filePath)
+				assert.NoError(t, err)
+
+				// Should be 0755 (setuid bit removed by & 0o777 mask)
+				assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
+
+				// Verify content is correct
+				content, err := os.ReadFile(filePath)
+				assert.NoError(t, err)
+				assert.Equal(t, "test content", string(content))
+			},
+		},
+	}
+
+	runner := &tarExtractor{}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			workDir, tarPath := tt.setupFiles(t)
+			extractDir := filepath.Join(workDir, "temp_extract")
+
+			result, err := runner.extractToDir(
+				context.Background(),
+				tt.cfg,
+				tarPath,
+				extractDir,
+			)
+
+			tt.assertions(t, workDir, extractDir, result, err)
+		})
+	}
+}
+
+func Test_tarExtractor_simpleAtomicMove(t *testing.T) {
+	tests := []struct {
+		name       string
+		setupFunc  func(*testing.T) (string, string)
+		assertions func(*testing.T, string, string, error)
+	}{
+		{
+			name: "successful move to non-existent destination",
+			setupFunc: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+				src := filepath.Join(tmpDir, "src")
+				dst := filepath.Join(tmpDir, "dst")
+
+				// Create source directory with content
+				require.NoError(t, os.MkdirAll(src, 0o755))
+				require.NoError(t, os.WriteFile(filepath.Join(src, "file.txt"), []byte("content"), 0o644))
+
+				return src, dst
+			},
+			assertions: func(t *testing.T, src, dst string, err error) {
+				assert.NoError(t, err)
+
+				// Source should no longer exist
+				_, err = os.Stat(src)
+				assert.True(t, os.IsNotExist(err))
+
+				// Destination should exist with content
+				content, err := os.ReadFile(filepath.Join(dst, "file.txt"))
+				assert.NoError(t, err)
+				assert.Equal(t, "content", string(content))
+			},
+		},
+		{
+			name: "successful move overwriting existing destination",
+			setupFunc: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+				src := filepath.Join(tmpDir, "src")
+				dst := filepath.Join(tmpDir, "dst")
+
+				// Create source directory with content
+				require.NoError(t, os.MkdirAll(src, 0o755))
+				require.NoError(t, os.WriteFile(filepath.Join(src, "new.txt"), []byte("new content"), 0o644))
+
+				// Create existing destination with different content
+				require.NoError(t, os.MkdirAll(dst, 0o755))
+				require.NoError(t, os.WriteFile(filepath.Join(dst, "old.txt"), []byte("old content"), 0o644))
+
+				return src, dst
+			},
+			assertions: func(t *testing.T, src, dst string, err error) {
+				assert.NoError(t, err)
+
+				// Source should no longer exist
+				_, err = os.Stat(src)
+				assert.True(t, os.IsNotExist(err))
+
+				// Destination should have new content, not old
+				content, err := os.ReadFile(filepath.Join(dst, "new.txt"))
+				assert.NoError(t, err)
+				assert.Equal(t, "new content", string(content))
+
+				// Old file should not exist
+				_, err = os.Stat(filepath.Join(dst, "old.txt"))
+				assert.True(t, os.IsNotExist(err))
+			},
+		},
+		{
+			name: "fails when source doesn't exist",
+			setupFunc: func(t *testing.T) (string, string) {
+				tmpDir := t.TempDir()
+				src := filepath.Join(tmpDir, "nonexistent")
+				dst := filepath.Join(tmpDir, "dst")
+				return src, dst
+			},
+			assertions: func(t *testing.T, src, dst string, err error) {
+				assert.Error(t, err)
+
+				// Neither should exist
+				_, err = os.Stat(src)
+				assert.True(t, os.IsNotExist(err))
+				_, err = os.Stat(dst)
+				assert.True(t, os.IsNotExist(err))
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			extractor := &tarExtractor{}
+			src, dst := tt.setupFunc(t)
+
+			err := extractor.simpleAtomicMove(src, dst)
+			tt.assertions(t, src, dst, err)
+		})
+	}
+}
+
+func Test_tarExtractor_validRelPath(t *testing.T) {
+	extractor := &tarExtractor{}
+
+	tests := []struct {
+		path     string
+		expected bool
+	}{
+		{"file.txt", true},
+		{"dir/file.txt", true},
+		{"dir/subdir/file.txt", true},
+		{"", false},                      // Empty path
+		{"/absolute/path", false},        // Absolute path
+		{"../traversal", false},          // Path traversal
+		{"dir/../file", false},           // Path traversal
+		{"./current", false},             // Current directory
+		{"file\\with\\backslash", false}, // Backslash (Windows path separator)
+	}
+
+	for _, tt := range tests {
+		t.Run(fmt.Sprintf("path_%s", tt.path), func(t *testing.T) {
+			result := extractor.validRelPath(tt.path)
+			assert.Equal(t, tt.expected, result, "Path: %s", tt.path)
+		})
+	}
+}