github.com/ddev/ddev@v1.23.2-0.20240519125000-d824ffe36ff3/pkg/archive/archive.go (about)

     1  package archive
     2  
     3  import (
     4  	"archive/tar"
     5  	"archive/zip"
     6  	"bufio"
     7  	"compress/bzip2"
     8  	"compress/gzip"
     9  	"fmt"
    10  	"github.com/ddev/ddev/pkg/fileutil"
    11  	"io"
    12  	"io/fs"
    13  	"os"
    14  	"path"
    15  	"path/filepath"
    16  	"runtime"
    17  	"strings"
    18  
    19  	"github.com/ddev/ddev/pkg/util"
    20  	"github.com/ulikunitz/xz"
    21  )
    22  
    23  // Ungzip accepts a gzipped file and uncompresses it to the provided destination directory.
    24  func Ungzip(source string, destDirectory string) error {
    25  	f, err := os.Open(source)
    26  	if err != nil {
    27  		return err
    28  	}
    29  
    30  	defer func() {
    31  		if e := f.Close(); e != nil {
    32  			err = e
    33  		}
    34  	}()
    35  
    36  	gf, err := gzip.NewReader(f)
    37  	if err != nil {
    38  		return err
    39  	}
    40  
    41  	defer func() {
    42  		if e := gf.Close(); e != nil {
    43  			err = e
    44  		}
    45  	}()
    46  
    47  	fname := strings.TrimSuffix(filepath.Base(f.Name()), ".gz")
    48  	exFile, err := os.Create(filepath.Join(destDirectory, fname))
    49  	if err != nil {
    50  		return err
    51  	}
    52  
    53  	defer func() {
    54  		if e := exFile.Close(); e != nil {
    55  			err = e
    56  		}
    57  	}()
    58  
    59  	_, err = io.Copy(exFile, gf)
    60  	if err != nil {
    61  		return err
    62  	}
    63  
    64  	err = exFile.Sync()
    65  	if err != nil {
    66  		return err
    67  	}
    68  
    69  	return nil
    70  }
    71  
    72  // UnBzip2 accepts a bzip2-compressed file and uncompresses it to the provided destination directory.
    73  func UnBzip2(source string, destDirectory string) error {
    74  	f, err := os.Open(source)
    75  	if err != nil {
    76  		return err
    77  	}
    78  
    79  	defer func() {
    80  		if e := f.Close(); e != nil {
    81  			err = e
    82  		}
    83  	}()
    84  	br := bufio.NewReader(f)
    85  
    86  	gf := bzip2.NewReader(br)
    87  
    88  	fname := strings.TrimSuffix(filepath.Base(f.Name()), ".bz2")
    89  	exFile, err := os.Create(filepath.Join(destDirectory, fname))
    90  	if err != nil {
    91  		return err
    92  	}
    93  
    94  	defer func() {
    95  		if e := exFile.Close(); e != nil {
    96  			err = e
    97  		}
    98  	}()
    99  
   100  	_, err = io.Copy(exFile, gf)
   101  	if err != nil {
   102  		return err
   103  	}
   104  
   105  	err = exFile.Sync()
   106  	if err != nil {
   107  		return err
   108  	}
   109  
   110  	return nil
   111  }
   112  
   113  // UnXz accepts an xz-compressed file and uncompresses it to the provided destination directory.
   114  func UnXz(source string, destDirectory string) error {
   115  	f, err := os.Open(source)
   116  	if err != nil {
   117  		return err
   118  	}
   119  
   120  	defer func() {
   121  		if e := f.Close(); e != nil {
   122  			err = e
   123  		}
   124  	}()
   125  	br := bufio.NewReader(f)
   126  
   127  	gf, err := xz.NewReader(br)
   128  	if err != nil {
   129  		return err
   130  	}
   131  
   132  	fname := strings.TrimSuffix(filepath.Base(f.Name()), ".xz")
   133  	exFile, err := os.Create(filepath.Join(destDirectory, fname))
   134  	if err != nil {
   135  		return err
   136  	}
   137  
   138  	defer func() {
   139  		if e := exFile.Close(); e != nil {
   140  			err = e
   141  		}
   142  	}()
   143  
   144  	_, err = io.Copy(exFile, gf)
   145  	if err != nil {
   146  		return err
   147  	}
   148  
   149  	err = exFile.Sync()
   150  	if err != nil {
   151  		return err
   152  	}
   153  
   154  	return nil
   155  }
   156  
   157  // Untar accepts a tar, tar.gz, tar.bz2, tar.xz file and extracts the contents to the provided destination path.
   158  // extractionDir is the path at which extraction should start; nothing will be extracted except the contents of
   159  // extractionDir. If extranctionDir is empty, the entire tarball is extracted.
   160  func Untar(source string, dest string, extractionDir string) error {
   161  	var tf *tar.Reader
   162  	f, err := os.Open(source)
   163  	if err != nil {
   164  		return err
   165  	}
   166  
   167  	defer util.CheckClose(f)
   168  
   169  	if err = os.MkdirAll(dest, 0755); err != nil {
   170  		return err
   171  	}
   172  
   173  	switch {
   174  	case strings.HasSuffix(source, "gz"):
   175  		gf, err := gzip.NewReader(f)
   176  		if err != nil {
   177  			return err
   178  		}
   179  		defer util.CheckClose(gf)
   180  		tf = tar.NewReader(gf)
   181  
   182  	case strings.HasSuffix(source, "xz"):
   183  		gf, err := xz.NewReader(f)
   184  		if err != nil {
   185  			return err
   186  		}
   187  		tf = tar.NewReader(gf)
   188  
   189  	case strings.HasSuffix(source, "bz2"):
   190  		br := bufio.NewReader(f)
   191  		gf := bzip2.NewReader(br)
   192  		if err != nil {
   193  			return err
   194  		}
   195  		tf = tar.NewReader(gf)
   196  
   197  	default:
   198  		tf = tar.NewReader(f)
   199  	}
   200  
   201  	// Define a boolean that indicates whether or not at least one
   202  	// file matches the extraction directory.
   203  	foundPathMatch := false
   204  	for {
   205  		file, err := tf.Next()
   206  		if err == io.EOF {
   207  			break
   208  		}
   209  		if err != nil {
   210  			return fmt.Errorf("error during read of tar archive %v, err: %v", source, err)
   211  		}
   212  
   213  		// If we have an extractionDir and this doesn't match, skip it.
   214  		if !strings.HasPrefix(file.Name, extractionDir) {
   215  			continue
   216  		}
   217  
   218  		// If we haven't continue-ed above, the file matches the extraction dir and this flag
   219  		// should be ensured to be true.
   220  		foundPathMatch = true
   221  
   222  		// If extractionDir matches file name and isn't a directory, we should be extracting a specific file.
   223  		if file.Name == extractionDir && file.Typeflag != tar.TypeDir {
   224  			file.Name = filepath.Base(file.Name)
   225  		} else {
   226  			// Transform the filename to skip the extractionDir
   227  			file.Name = strings.TrimPrefix(file.Name, extractionDir)
   228  		}
   229  
   230  		// If file.Name is now empty this is the root directory we want to extract, and need not do anything.
   231  		if file.Name == "" && file.Typeflag == tar.TypeDir {
   232  			continue
   233  		}
   234  
   235  		fullPath := filepath.Join(dest, file.Name)
   236  
   237  		// At this point only directories and block-files are handled. Symlinks and the like are ignored.
   238  		switch file.Typeflag {
   239  		case tar.TypeDir:
   240  			// For a directory, if it doesn't exist, we create it.
   241  			finfo, err := os.Stat(fullPath)
   242  			if err == nil && finfo.IsDir() {
   243  				continue
   244  			}
   245  
   246  			err = os.MkdirAll(fullPath, 0755)
   247  			if err != nil {
   248  				return err
   249  			}
   250  
   251  			err = os.Chmod(fullPath, fs.FileMode(file.Mode))
   252  			if err != nil {
   253  				return fmt.Errorf("failed to chmod %v dir %v, err: %v", fs.FileMode(file.Mode), fullPath, err)
   254  			}
   255  
   256  		case tar.TypeReg:
   257  			// Always ensure the directory is created before trying to move the file.
   258  			fullPathDir := filepath.Dir(fullPath)
   259  			err = os.MkdirAll(fullPathDir, 0755)
   260  			if err != nil {
   261  				return fmt.Errorf("failed to create the directory %s, err: %v", fullPathDir, err)
   262  			}
   263  
   264  			// For a regular file, create and copy the file.
   265  			exFile, err := os.Create(fullPath)
   266  			if err != nil {
   267  				return fmt.Errorf("failed to create file %v, err: %v", fullPath, err)
   268  			}
   269  			_, err = io.Copy(exFile, tf)
   270  			_ = exFile.Close()
   271  			if err != nil {
   272  				return fmt.Errorf("failed to copy to file %v, err: %v", fullPath, err)
   273  			}
   274  			err = os.Chmod(fullPath, fs.FileMode(file.Mode))
   275  			if err != nil {
   276  				return fmt.Errorf("failed to chmod %v file %v, err: %v", fs.FileMode(file.Mode), fullPath, err)
   277  			}
   278  
   279  		}
   280  	}
   281  
   282  	// If no files matched the extraction path, return an error.
   283  	if !foundPathMatch {
   284  		return fmt.Errorf("failed to find files in extraction path: %s", extractionDir)
   285  	}
   286  
   287  	return nil
   288  }
   289  
   290  // Unzip accepts a zip file and extracts the contents to the provided destination path.
   291  // extractionDir is the path at which extraction should szipt; nothing will be extracted except the contents of
   292  // extractionDir
   293  func Unzip(source string, dest string, extractionDir string) error {
   294  	zf, err := zip.OpenReader(source)
   295  	if err != nil {
   296  		return fmt.Errorf("failed to open zipfile %s, err:%v", source, err)
   297  	}
   298  	defer util.CheckClose(zf)
   299  
   300  	if err = os.MkdirAll(dest, 0755); err != nil {
   301  		return err
   302  	}
   303  
   304  	// Define a boolean that indicates whether or not at least one
   305  	// file matches the extraction directory.
   306  	foundPathMatch := false
   307  	for _, file := range zf.File {
   308  		// If we have an extractionDir and this doesn't match, skip it.
   309  		if !strings.HasPrefix(file.Name, extractionDir) {
   310  			continue
   311  		}
   312  
   313  		// If we haven't continue-ed above, the file matches the extraction dir and this flag
   314  		// should be ensured to be true.
   315  		foundPathMatch = true
   316  
   317  		// If extractionDir matches file name and isn't a directory, we should be extracting a specific file.
   318  		fileInfo := file.FileInfo()
   319  		if file.Name == extractionDir && !fileInfo.IsDir() {
   320  			file.Name = filepath.Base(file.Name)
   321  		} else {
   322  			// Transform the filename to skip the extractionDir
   323  			file.Name = strings.TrimPrefix(file.Name, extractionDir)
   324  		}
   325  
   326  		fullPath := filepath.Join(dest, file.Name)
   327  
   328  		if strings.HasSuffix(file.Name, "/") {
   329  			err = os.MkdirAll(fullPath, 0777)
   330  			if err != nil {
   331  				return fmt.Errorf("failed to mkdir %s, err:%v", fullPath, err)
   332  			}
   333  			continue
   334  		}
   335  
   336  		// If file.Name is now empty this is the root directory we want to extract, and need not do anything.
   337  		if file.Name == "" {
   338  			continue
   339  		}
   340  
   341  		rc, err := file.Open()
   342  		if err != nil {
   343  			return err
   344  		}
   345  
   346  		// create and copy the file.
   347  		exFile, err := os.Create(fullPath)
   348  		if err != nil {
   349  			return fmt.Errorf("failed to create file %v, err: %v", fullPath, err)
   350  		}
   351  		_, err = io.Copy(exFile, rc)
   352  		_ = exFile.Close()
   353  		if err != nil {
   354  			return fmt.Errorf("failed to copy to file %v, err: %v", fullPath, err)
   355  		}
   356  	}
   357  
   358  	// If no files matched the extraction path, return an error.
   359  	if !foundPathMatch {
   360  		return fmt.Errorf("failed to find files in extraction path: %s", extractionDir)
   361  	}
   362  
   363  	return nil
   364  }
   365  
   366  // Tar takes a source dir and tarballFilePath and a single exclusion path
   367  // It creates a gzipped tarball.
   368  // So sorry that exclusion is a single relative path. It should be a set of patterns, rfay 2021-12-15
   369  func Tar(src string, tarballFilePath string, exclusion string) error {
   370  	// ensure the src actually exists before trying to tar it
   371  	if _, err := os.Stat(src); err != nil {
   372  		return fmt.Errorf("unable to tar files - %v", err.Error())
   373  	}
   374  	separator := string(rune(filepath.Separator))
   375  
   376  	tarball, err := os.Create(tarballFilePath)
   377  	if err != nil {
   378  		return fmt.Errorf("could not create tarball file '%s', got error '%s'", tarballFilePath, err.Error())
   379  	}
   380  	// nolint: errcheck
   381  	defer tarball.Close()
   382  
   383  	mw := io.MultiWriter(tarball)
   384  
   385  	gzw := gzip.NewWriter(mw)
   386  	defer gzw.Close()
   387  
   388  	tw := tar.NewWriter(gzw)
   389  	defer tw.Close()
   390  
   391  	// walk path
   392  	return filepath.WalkDir(src, func(file string, info fs.DirEntry, errArg error) error {
   393  		// return on any error
   394  		if errArg != nil {
   395  			return errArg
   396  		}
   397  
   398  		relativePath := strings.TrimPrefix(file, src+separator)
   399  
   400  		if exclusion != "" && strings.HasPrefix(relativePath, exclusion) {
   401  			return nil
   402  		}
   403  
   404  		// return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update)
   405  		fi, err := info.Info()
   406  		if err != nil {
   407  			return nil
   408  		}
   409  		if !fi.Mode().IsRegular() {
   410  			return nil
   411  		}
   412  
   413  		// create a new dir/file header
   414  		header, err := tar.FileInfoHeader(fi, fi.Name())
   415  		if err != nil {
   416  			return err
   417  		}
   418  		// Windows may not get zero size of file, https://github.com/golang/go/issues/23493
   419  		// No idea why fi.Size() comes through as zero for a few files
   420  		stat, err := os.Stat(file)
   421  		if err != nil {
   422  			return err
   423  		}
   424  		header.Size = stat.Size()
   425  
   426  		// open files for tarring
   427  		f, err := os.Open(file)
   428  		if err != nil {
   429  			return err
   430  		}
   431  
   432  		// Windows filesystem has no concept of executable bit, but we're copying shell scripts
   433  		// and they need to be executable. So if we detect a shell script
   434  		// set its mode to executable. It seems this is what utilities like git-bash
   435  		// and cygwin, etc. have done for years to work around the lack of mode bits on NTFS,
   436  		// for example, see https://stackoverflow.com/a/25730108/215713
   437  		if runtime.GOOS == "windows" {
   438  			buffer := make([]byte, 16)
   439  			_, _ = f.Read(buffer)
   440  			_, _ = f.Seek(0, 0)
   441  			if strings.HasPrefix(string(buffer), "#!") {
   442  				header.Mode = 0755
   443  			}
   444  		}
   445  
   446  		// update the name to correctly reflect the desired destination when untarring
   447  		header.Name = strings.TrimPrefix(strings.Replace(file, src, "", -1), string(filepath.Separator))
   448  		if runtime.GOOS == "windows" {
   449  			header.Name = strings.Replace(header.Name, `\`, `/`, -1)
   450  		}
   451  
   452  		// write the header
   453  		if err := tw.WriteHeader(header); err != nil {
   454  			return err
   455  		}
   456  
   457  		// copy file data into tar writer
   458  		if _, err := io.Copy(tw, f); err != nil {
   459  			return err
   460  		}
   461  
   462  		// manually close here after each file operation; deferring would cause each file close
   463  		// to wait until all operations have completed.
   464  		f.Close()
   465  
   466  		return nil
   467  	})
   468  }
   469  
   470  // DownloadAndExtractTarball takes an url to a tar.gz file and
   471  // extracts into a new a temp directory and the directory
   472  // and a cleanup function.
   473  // It's the caller's responsibility to call the cleanup function.
   474  func DownloadAndExtractTarball(url string, removeTopLevel bool) (string, func(), error) {
   475  	base := filepath.Base(url)
   476  	f, err := os.CreateTemp("", fmt.Sprintf("%s_*.tar.gz", base))
   477  	if err != nil {
   478  		return "", nil, fmt.Errorf("unable to create temp file: %v", err)
   479  	}
   480  	defer func() {
   481  		_ = f.Close()
   482  	}()
   483  
   484  	util.Success("Downloading %s", url)
   485  	tarball := f.Name()
   486  	defer func() {
   487  		_ = os.Remove(tarball)
   488  	}()
   489  
   490  	err = util.DownloadFile(tarball, url, true)
   491  	if err != nil {
   492  		return "", nil, fmt.Errorf("unable to download %v: %v", url, err)
   493  	}
   494  	extractedDir, cleanup, err := ExtractTarballWithCleanup(tarball, removeTopLevel)
   495  	return extractedDir, cleanup, err
   496  }
   497  
   498  // ExtractTarballWithCleanup takes a tarball file and extracts it into a temp directory
   499  // Caller is responsible for cleanup of the temp directory using the returned
   500  // cleanup function.
   501  // If removeTopLevel is true, the top level directory will be removed.
   502  func ExtractTarballWithCleanup(tarball string, removeTopLevel bool) (string, func(), error) {
   503  	tmpDir, err := os.MkdirTemp("", fmt.Sprintf("ddev_%s_*", filepath.Base(tarball)))
   504  	if err != nil {
   505  		return "", nil, fmt.Errorf("unable to create temp dir: %v", err)
   506  	}
   507  
   508  	err = Untar(tarball, tmpDir, "")
   509  	if err != nil {
   510  		return "", nil, fmt.Errorf("unable to untar %v: %v", tmpDir, err)
   511  	}
   512  
   513  	// If removeTopLevel then the guts of the tarball are the first level directory
   514  	// Really the UnTar() function should take strip-components as an argument
   515  	// but not going to do that right now.
   516  	extractedDir := tmpDir
   517  	if removeTopLevel {
   518  		list, err := fileutil.ListFilesInDir(tmpDir)
   519  		if err != nil {
   520  			return "", nil, fmt.Errorf("unable to list files in %v: %v", tmpDir, err)
   521  		}
   522  		if len(list) == 0 {
   523  			return "", nil, fmt.Errorf("no files found in %v", tmpDir)
   524  		}
   525  		extractedDir = path.Join(tmpDir, list[0])
   526  	}
   527  	cleanupFunc := func() { _ = os.RemoveAll(tmpDir) }
   528  	return extractedDir, cleanupFunc, nil
   529  }