diff --git a/integration/helpers/archive.go b/integration/helpers/archive.go
new file mode 100644
index 0000000000000..6e48108013d86
--- /dev/null
+++ b/integration/helpers/archive.go
@@ -0,0 +1,170 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package helpers
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/gzip"
+ "context"
+ "io"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+)
+
+// CompressDirToZipFile compresses a source directory into `.zip` format and stores at `archivePath`,
+// preserving the relative file path structure of the source directory.
+func CompressDirToZipFile(ctx context.Context, sourceDir, archivePath string) (err error) {
+ archive, err := os.Create(archivePath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer func() {
+ if closeErr := archive.Close(); closeErr != nil {
+ err = trace.NewAggregate(err, closeErr)
+ return
+ }
+ if err != nil {
+ if err := os.Remove(archivePath); err != nil {
+ slog.ErrorContext(ctx, "failed to remove archive", "error", err)
+ }
+ }
+ }()
+
+ zipWriter := zip.NewWriter(archive)
+ err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if info.IsDir() {
+ return nil
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer file.Close()
+ relPath, err := filepath.Rel(sourceDir, path)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ zipFileWriter, err := zipWriter.Create(relPath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if _, err = io.Copy(zipFileWriter, file); err != nil {
+ return trace.Wrap(err)
+ }
+ return trace.Wrap(file.Close())
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if err = zipWriter.Close(); err != nil {
+ return trace.Wrap(err)
+ }
+
+ return
+}
+
+// CompressDirToTarGzFile compresses a source directory into .tar.gz format and stores at `archivePath`,
+// preserving the relative file path structure of the source directory.
+func CompressDirToTarGzFile(ctx context.Context, sourceDir, archivePath string) (err error) {
+ archive, err := os.Create(archivePath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer func() {
+ if closeErr := archive.Close(); closeErr != nil {
+ err = trace.NewAggregate(err, closeErr)
+ return
+ }
+ if err != nil {
+ if err := os.Remove(archivePath); err != nil {
+ slog.ErrorContext(ctx, "failed to remove archive", "error", err)
+ }
+ }
+ }()
+ gzipWriter := gzip.NewWriter(archive)
+ tarWriter := tar.NewWriter(gzipWriter)
+ err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return nil
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ header, err := tar.FileInfoHeader(info, info.Name())
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ header.Name, err = filepath.Rel(sourceDir, path)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if err := tarWriter.WriteHeader(header); err != nil {
+ return trace.Wrap(err)
+ }
+ if _, err = io.Copy(tarWriter, file); err != nil {
+ return trace.Wrap(err)
+ }
+ return trace.Wrap(file.Close())
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if err = tarWriter.Close(); err != nil {
+ return trace.Wrap(err)
+ }
+ if err = gzipWriter.Close(); err != nil {
+ return trace.Wrap(err)
+ }
+
+ return
+}
+
+// CompressDirToPkgFile runs for the macOS `pkgbuild` command to generate a .pkg
+// archive file from the source directory.
+func CompressDirToPkgFile(ctx context.Context, sourceDir, archivePath, identifier string) error {
+ if runtime.GOOS != "darwin" {
+ return trace.BadParameter("only darwin platform is supported for pkg file")
+ }
+ cmd := exec.CommandContext(
+ ctx,
+ "pkgbuild",
+ "--root", sourceDir,
+ "--identifier", identifier,
+ "--version", teleport.Version,
+ archivePath,
+ )
+
+ return trace.Wrap(cmd.Run())
+}
diff --git a/lib/utils/disk.go b/lib/utils/disk.go
index 78fba5457099b..9e2419527051a 100644
--- a/lib/utils/disk.go
+++ b/lib/utils/disk.go
@@ -46,6 +46,23 @@ func PercentUsed(path string) (float64, error) {
return Round(ratio * 100), nil
}
+// FreeDiskWithReserve returns the available disk space (in bytes) on the disk at dir, minus `reservedFreeDisk`.
+func FreeDiskWithReserve(dir string, reservedFreeDisk uint64) (uint64, error) {
+ var stat syscall.Statfs_t
+ err := syscall.Statfs(dir, &stat)
+ if err != nil {
+ return 0, trace.Wrap(err)
+ }
+ if stat.Bsize < 0 {
+ return 0, trace.Errorf("invalid size")
+ }
+ avail := stat.Bavail * uint64(stat.Bsize)
+ if reservedFreeDisk > avail {
+ return 0, trace.Errorf("no free space left")
+ }
+ return avail - reservedFreeDisk, nil
+}
+
// CanUserWriteTo attempts to check if a user has write access to certain path.
// It also works around the program being run as root and tries to check
// the permissions of the user who executed the program as root.
diff --git a/lib/utils/disk_windows.go b/lib/utils/disk_windows.go
index cde568b4c9589..8056849afa82b 100644
--- a/lib/utils/disk_windows.go
+++ b/lib/utils/disk_windows.go
@@ -21,13 +21,29 @@
package utils
-import "github.com/gravitational/trace"
+import (
+ "github.com/gravitational/trace"
+ "golang.org/x/sys/windows"
+)
// PercentUsed is not supported on Windows.
func PercentUsed(path string) (float64, error) {
return 0.0, trace.NotImplemented("disk usage not supported on Windows")
}
+// FreeDiskWithReserve returns the available disk space (in bytes) on the disk at dir, minus `reservedFreeDisk`.
+func FreeDiskWithReserve(dir string, reservedFreeDisk uint64) (uint64, error) {
+ var avail uint64
+ err := windows.GetDiskFreeSpaceEx(windows.StringToUTF16Ptr(dir), &avail, nil, nil)
+ if err != nil {
+ return 0, trace.Wrap(err)
+ }
+ if reservedFreeDisk > avail {
+ return 0, trace.Errorf("no free space left")
+ }
+ return avail - reservedFreeDisk, nil
+}
+
// CanUserWriteTo is not supported on Windows.
func CanUserWriteTo(path string) (bool, error) {
return false, trace.NotImplemented("path permission checking is not supported on Windows")
diff --git a/lib/utils/packaging/unarchive.go b/lib/utils/packaging/unarchive.go
new file mode 100644
index 0000000000000..f1a197e095b1a
--- /dev/null
+++ b/lib/utils/packaging/unarchive.go
@@ -0,0 +1,158 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package packaging
+
+import (
+ "archive/zip"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+const (
+ // reservedFreeDisk is the predefined amount of free disk space (in bytes) required
+ // to remain available after extracting Teleport binaries.
+ reservedFreeDisk = 10 * 1024 * 1024
+)
+
+// RemoveWithSuffix removes all that matches the provided suffix, except for file or directory with `skipName`.
+func RemoveWithSuffix(dir, suffix, skipName string) error {
+ var removePaths []string
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if skipName == info.Name() {
+ return nil
+ }
+ if !strings.HasSuffix(info.Name(), suffix) {
+ return nil
+ }
+ removePaths = append(removePaths, path)
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ for _, path := range removePaths {
+ if err := os.RemoveAll(path); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+ return nil
+}
+
+// replaceZip un-archives the Teleport package in .zip format, iterates through
+// the compressed content, and ignores everything not matching the binaries specified
+// in the execNames argument. The data is extracted to extractDir, and symlinks are created
+// in toolsDir pointing to the extractDir path with binaries.
+func replaceZip(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer f.Close()
+
+ fi, err := f.Stat()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ zipReader, err := zip.NewReader(f, fi.Size())
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ var totalSize uint64 = 0
+ for _, zipFile := range zipReader.File {
+ baseName := filepath.Base(zipFile.Name)
+ // Skip over any files in the archive that are not defined execNames.
+ if !slices.ContainsFunc(execNames, func(s string) bool {
+ return baseName == s
+ }) {
+ continue
+ }
+ totalSize += zipFile.UncompressedSize64
+ }
+ // Verify that we have enough space for uncompressed zipFile.
+ if err := checkFreeSpace(extractDir, totalSize); err != nil {
+ return trace.Wrap(err)
+ }
+
+ for _, zipFile := range zipReader.File {
+ baseName := filepath.Base(zipFile.Name)
+ // Skip over any files in the archive that are not defined execNames.
+ if !slices.Contains(execNames, baseName) {
+ continue
+ }
+
+ if err := func(zipFile *zip.File) error {
+ file, err := zipFile.Open()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer file.Close()
+
+ dest := filepath.Join(extractDir, baseName)
+ destFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer destFile.Close()
+
+ if _, err := io.Copy(destFile, file); err != nil {
+ return trace.Wrap(err)
+ }
+ appPath := filepath.Join(toolsDir, baseName)
+ if err := os.Remove(appPath); err != nil && !os.IsNotExist(err) {
+ return trace.Wrap(err)
+ }
+ if err := os.Symlink(dest, appPath); err != nil {
+ return trace.Wrap(err)
+ }
+ return trace.Wrap(destFile.Close())
+ }(zipFile); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ return nil
+}
+
+// checkFreeSpace verifies that we have enough requested space (in bytes) at specific directory.
+func checkFreeSpace(path string, requested uint64) error {
+ free, err := utils.FreeDiskWithReserve(path, reservedFreeDisk)
+ if err != nil {
+ return trace.Errorf("failed to calculate free disk in %q: %v", path, err)
+ }
+ // Bail if there's not enough free disk space at the target.
+ if requested > free {
+ return trace.Errorf("%q needs %d additional bytes of disk space", path, requested-free)
+ }
+
+ return nil
+}
diff --git a/lib/utils/packaging/unarchive_test.go b/lib/utils/packaging/unarchive_test.go
new file mode 100644
index 0000000000000..30933bbb75927
--- /dev/null
+++ b/lib/utils/packaging/unarchive_test.go
@@ -0,0 +1,147 @@
+//go:build !windows
+
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package packaging
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/integration/helpers"
+)
+
+// TestPackaging verifies un-archiving of all supported teleport package formats.
+func TestPackaging(t *testing.T) {
+ script := "#!/bin/sh\necho test"
+
+ sourceDir, err := os.MkdirTemp(os.TempDir(), "source")
+ require.NoError(t, err)
+
+ toolsDir, err := os.MkdirTemp(os.TempDir(), "dest")
+ require.NoError(t, err)
+
+ extractDir, err := os.MkdirTemp(toolsDir, "extract")
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ require.NoError(t, os.RemoveAll(extractDir))
+ require.NoError(t, os.RemoveAll(sourceDir))
+ require.NoError(t, os.RemoveAll(toolsDir))
+ })
+
+ // Create test script for packaging in relative path `teleport\bin` to ensure that
+ // binaries going to be identified and extracted flatten to `extractDir`.
+ binPath := filepath.Join(sourceDir, "teleport", "bin")
+ require.NoError(t, os.MkdirAll(binPath, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(binPath, "tsh"), []byte(script), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(binPath, "tctl"), []byte(script), 0o755))
+
+ ctx := context.Background()
+
+ t.Run("tar.gz", func(t *testing.T) {
+ archivePath := filepath.Join(toolsDir, "tsh.tar.gz")
+ err = helpers.CompressDirToTarGzFile(ctx, sourceDir, archivePath)
+ require.NoError(t, err)
+ require.FileExists(t, archivePath, "archive not created")
+
+ // For the .tar.gz format we extract app by app to check that content discard is not required.
+ err = replaceTarGz(toolsDir, archivePath, extractDir, []string{"tctl"})
+ require.NoError(t, err)
+ err = replaceTarGz(toolsDir, archivePath, extractDir, []string{"tsh"})
+ require.NoError(t, err)
+ assert.FileExists(t, filepath.Join(toolsDir, "tsh"), "script not created")
+ assert.FileExists(t, filepath.Join(toolsDir, "tctl"), "script not created")
+
+ data, err := os.ReadFile(filepath.Join(toolsDir, "tsh"))
+ require.NoError(t, err)
+ assert.Equal(t, script, string(data))
+ })
+
+ t.Run("pkg", func(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("unsupported platform")
+ }
+ archivePath := filepath.Join(toolsDir, "tsh.pkg")
+ err = helpers.CompressDirToPkgFile(ctx, sourceDir, archivePath, "com.example.pkgtest")
+ require.NoError(t, err)
+ require.FileExists(t, archivePath, "archive not created")
+
+ err = replacePkg(toolsDir, archivePath, filepath.Join(extractDir, "apps"), []string{"tsh", "tctl"})
+ require.NoError(t, err)
+ assert.FileExists(t, filepath.Join(toolsDir, "tsh"), "script not created")
+ assert.FileExists(t, filepath.Join(toolsDir, "tctl"), "script not created")
+
+ data, err := os.ReadFile(filepath.Join(toolsDir, "tsh"))
+ require.NoError(t, err)
+ assert.Equal(t, script, string(data))
+ })
+
+ t.Run("zip", func(t *testing.T) {
+ archivePath := filepath.Join(toolsDir, "tsh.zip")
+ err = helpers.CompressDirToZipFile(ctx, sourceDir, archivePath)
+ require.NoError(t, err)
+ require.FileExists(t, archivePath, "archive not created")
+
+ err = replaceZip(toolsDir, archivePath, extractDir, []string{"tsh", "tctl"})
+ require.NoError(t, err)
+ assert.FileExists(t, filepath.Join(toolsDir, "tsh"), "script not created")
+ assert.FileExists(t, filepath.Join(toolsDir, "tctl"), "script not created")
+
+ data, err := os.ReadFile(filepath.Join(toolsDir, "tsh"))
+ require.NoError(t, err)
+ assert.Equal(t, script, string(data))
+ })
+}
+
+// TestRemoveWithSuffix verifies that helper for the cleanup removes directories
+func TestRemoveWithSuffix(t *testing.T) {
+ testDir := t.TempDir()
+ dirForRemove := "test-extract-pkg"
+
+ // Creates directories `test/test-extract-pkg/test-extract-pkg` with exact names
+ // to ensure that only root one going to be removed recursively without any error.
+ path := filepath.Join(testDir, dirForRemove, dirForRemove)
+ require.NoError(t, os.MkdirAll(path, 0o755))
+ // Also we create the directory that needs to be skipped, and it matches the remove
+ // pattern `test/skip-test-extract-pkg/test-extract-pkg`.
+ skipName := "skip-" + dirForRemove
+ skipPath := filepath.Join(testDir, skipName)
+ dirInSkipPath := filepath.Join(skipPath, dirForRemove)
+ require.NoError(t, os.MkdirAll(skipPath, 0o755))
+
+ err := RemoveWithSuffix(testDir, dirForRemove, skipName)
+ require.NoError(t, err)
+
+ _, err = os.Stat(filepath.Join(testDir, dirForRemove))
+ assert.True(t, os.IsNotExist(err))
+
+ filePath, err := os.Stat(skipPath)
+ require.NoError(t, err)
+ assert.True(t, filePath.IsDir())
+
+ _, err = os.Stat(dirInSkipPath)
+ assert.True(t, os.IsNotExist(err))
+}
diff --git a/lib/utils/packaging/unarchive_unix.go b/lib/utils/packaging/unarchive_unix.go
new file mode 100644
index 0000000000000..3be7d0c473ef9
--- /dev/null
+++ b/lib/utils/packaging/unarchive_unix.go
@@ -0,0 +1,205 @@
+//go:build !windows
+
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package packaging
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "slices"
+
+ "github.com/google/renameio/v2"
+ "github.com/gravitational/trace"
+)
+
+// ReplaceToolsBinaries extracts executables specified by execNames from archivePath into
+// extractDir. After each executable is extracted, it is symlinked from extractDir/[name] to
+// toolsDir/[name].
+//
+// For Darwin, archivePath must be a .pkg file.
+// For other POSIX, archivePath must be a gzipped tarball.
+func ReplaceToolsBinaries(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+ switch runtime.GOOS {
+ case "darwin":
+ return replacePkg(toolsDir, archivePath, extractDir, execNames)
+ default:
+ return replaceTarGz(toolsDir, archivePath, extractDir, execNames)
+ }
+}
+
+// replaceTarGz un-archives the Teleport package in .tar.gz format, iterates through
+// the compressed content, and ignores everything not matching the app binaries specified
+// in the apps argument. The data is extracted to extractDir, and symlinks are created
+// in toolsDir pointing to the extractDir path with binaries.
+func replaceTarGz(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+ if err := validateFreeSpaceTarGz(archivePath, extractDir, execNames); err != nil {
+ return trace.Wrap(err)
+ }
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer f.Close()
+
+ gzipReader, err := gzip.NewReader(f)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ tarReader := tar.NewReader(gzipReader)
+ for {
+ header, err := tarReader.Next()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ baseName := filepath.Base(header.Name)
+ // Skip over any files in the archive that are not in execNames.
+ if !slices.Contains(execNames, baseName) {
+ continue
+ }
+
+ if err = func(header *tar.Header) error {
+ tempFile, err := renameio.TempFile(extractDir, filepath.Join(toolsDir, baseName))
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer tempFile.Cleanup()
+ if err := os.Chmod(tempFile.Name(), 0o755); err != nil {
+ return trace.Wrap(err)
+ }
+ if _, err := io.Copy(tempFile, tarReader); err != nil {
+ return trace.Wrap(err)
+ }
+ if err := tempFile.CloseAtomicallyReplace(); err != nil {
+ return trace.Wrap(err)
+ }
+ return trace.Wrap(tempFile.Cleanup())
+ }(header); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ return trace.Wrap(gzipReader.Close())
+}
+
+// validateFreeSpaceTarGz validates that extraction size match available disk space in `extractDir`.
+func validateFreeSpaceTarGz(archivePath string, extractDir string, execNames []string) error {
+ f, err := os.Open(archivePath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer f.Close()
+
+ var totalSize uint64
+ gzipReader, err := gzip.NewReader(f)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ tarReader := tar.NewReader(gzipReader)
+ for {
+ header, err := tarReader.Next()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ baseName := filepath.Base(header.Name)
+ // Skip over any files in the archive that are not defined execNames.
+ if !slices.Contains(execNames, baseName) {
+ continue
+ }
+ totalSize += uint64(header.Size)
+ }
+
+ return trace.Wrap(checkFreeSpace(extractDir, totalSize))
+}
+
+// replacePkg expands the Teleport package in .pkg format using the platform-dependent pkgutil utility.
+// The data is extracted to extractDir, and symlinks are created in toolsDir pointing to the binaries
+// in extractDir. Before creating the symlinks, each binary must be executed at least once to pass
+// OS signature verification.
+func replacePkg(toolsDir string, archivePath string, extractDir string, execNames []string) error {
+ // Use "pkgutil" from the filesystem to expand the archive. In theory .pkg
+ // files are xz archives, however it's still safer to use "pkgutil" in-case
+ // Apple makes non-standard changes to the format.
+ //
+ // Full command: pkgutil --expand-full NAME.pkg DIRECTORY/
+ pkgutil, err := exec.LookPath("pkgutil")
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ if err = exec.Command(pkgutil, "--expand-full", archivePath, extractDir).Run(); err != nil {
+ return trace.Wrap(err)
+ }
+
+ err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if info.IsDir() {
+ return nil
+ }
+ // Skip over any files in the archive that are not in execNames.
+ if !slices.ContainsFunc(execNames, func(s string) bool {
+ return filepath.Base(info.Name()) == s
+ }) {
+ return nil
+ }
+
+ // The first time a signed and notarized binary macOS application is run,
+ // execution is paused while it gets sent to Apple to verify. Once Apple
+ // approves the binary, the "com.apple.macl" extended attribute is added
+ // and the process is allowed to execute. This process is not concurrent, any
+ // other operations (like moving the application) on the application during
+ // this time will lead to the application being sent SIGKILL.
+ //
+ // Since apps have to be concurrent, execute app before performing any
+ // swap operations. This ensures that the "com.apple.macl" extended
+ // attribute is set and macOS will not send a SIGKILL to the process
+ // if multiple processes are trying to operate on it.
+ command := exec.Command(path, "version", "--client")
+ if err := command.Run(); err != nil {
+ return trace.Wrap(err)
+ }
+
+ // Due to macOS applications not being a single binary (they are a
+ // directory), atomic operations are not possible. To work around this, use
+ // a symlink (which can be atomically swapped), then do a cleanup pass
+ // removing any stale copies of the expanded package.
+ newName := filepath.Join(toolsDir, filepath.Base(path))
+ if err := renameio.Symlink(path, newName); err != nil {
+ return trace.Wrap(err)
+ }
+
+ return nil
+ })
+
+ return trace.Wrap(err)
+}
diff --git a/lib/utils/packaging/unarchive_windows.go b/lib/utils/packaging/unarchive_windows.go
new file mode 100644
index 0000000000000..c07471adce83c
--- /dev/null
+++ b/lib/utils/packaging/unarchive_windows.go
@@ -0,0 +1,30 @@
+//go:build windows
+
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package packaging
+
+// ReplaceToolsBinaries extracts executables specified by execNames from archivePath into
+// extractDir. After each executable is extracted, it is symlinked from extractDir/[name] to
+// toolsDir/[name].
+//
+// For Windows, archivePath must be a .zip file.
+func ReplaceToolsBinaries(toolsDir string, archivePath string, extractPath string, execNames []string) error {
+ return replaceZip(toolsDir, archivePath, extractPath, execNames)
+}