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) +}