github.com/safing/portbase@v0.19.5/updater/unpacking.go (about)

     1  package updater
     2  
     3  import (
     4  	"archive/zip"
     5  	"compress/gzip"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"io/fs"
    10  	"os"
    11  	"path"
    12  	"path/filepath"
    13  	"strings"
    14  
    15  	"github.com/hashicorp/go-multierror"
    16  
    17  	"github.com/safing/portbase/log"
    18  	"github.com/safing/portbase/utils"
    19  )
    20  
    21  // MaxUnpackSize specifies the maximum size that will be unpacked.
    22  const MaxUnpackSize = 1000000000 // 1GB
    23  
    24  // UnpackGZIP unpacks a GZIP compressed reader r
    25  // and returns a new reader. It's suitable to be
    26  // used with registry.GetPackedFile.
    27  func UnpackGZIP(r io.Reader) (io.Reader, error) {
    28  	return gzip.NewReader(r)
    29  }
    30  
    31  // UnpackResources unpacks all resources defined in the AutoUnpack list.
    32  func (reg *ResourceRegistry) UnpackResources() error {
    33  	reg.RLock()
    34  	defer reg.RUnlock()
    35  
    36  	var multierr *multierror.Error
    37  	for _, res := range reg.resources {
    38  		if utils.StringInSlice(reg.AutoUnpack, res.Identifier) {
    39  			err := res.UnpackArchive()
    40  			if err != nil {
    41  				multierr = multierror.Append(
    42  					multierr,
    43  					fmt.Errorf("%s: %w", res.Identifier, err),
    44  				)
    45  			}
    46  		}
    47  	}
    48  
    49  	return multierr.ErrorOrNil()
    50  }
    51  
    52  const (
    53  	zipSuffix = ".zip"
    54  )
    55  
    56  // UnpackArchive unpacks the archive the resource refers to. The contents are
    57  // unpacked into a directory with the same name as the file, excluding the
    58  // suffix. If the destination folder already exists, it is assumed that the
    59  // contents have already been correctly unpacked.
    60  func (res *Resource) UnpackArchive() error {
    61  	res.Lock()
    62  	defer res.Unlock()
    63  
    64  	// Only unpack selected versions.
    65  	if res.SelectedVersion == nil {
    66  		return nil
    67  	}
    68  
    69  	switch {
    70  	case strings.HasSuffix(res.Identifier, zipSuffix):
    71  		return res.unpackZipArchive()
    72  	default:
    73  		return fmt.Errorf("unsupported file type for unpacking")
    74  	}
    75  }
    76  
    77  func (res *Resource) unpackZipArchive() error {
    78  	// Get file and directory paths.
    79  	archiveFile := res.SelectedVersion.storagePath()
    80  	destDir := strings.TrimSuffix(archiveFile, zipSuffix)
    81  	tmpDir := filepath.Join(
    82  		res.registry.tmpDir.Path,
    83  		filepath.FromSlash(strings.TrimSuffix(
    84  			path.Base(res.SelectedVersion.versionedPath()),
    85  			zipSuffix,
    86  		)),
    87  	)
    88  
    89  	// Check status of destination.
    90  	dstStat, err := os.Stat(destDir)
    91  	switch {
    92  	case errors.Is(err, fs.ErrNotExist):
    93  		// The destination does not exist, continue with unpacking.
    94  	case err != nil:
    95  		return fmt.Errorf("cannot access destination for unpacking: %w", err)
    96  	case !dstStat.IsDir():
    97  		return fmt.Errorf("destination for unpacking is blocked by file: %s", dstStat.Name())
    98  	default:
    99  		// Archive already seems to be unpacked.
   100  		return nil
   101  	}
   102  
   103  	// Create the tmp directory for unpacking.
   104  	err = res.registry.tmpDir.EnsureAbsPath(tmpDir)
   105  	if err != nil {
   106  		return fmt.Errorf("failed to create tmp dir for unpacking: %w", err)
   107  	}
   108  
   109  	// Defer clean up of directories.
   110  	defer func() {
   111  		// Always clean up the tmp dir.
   112  		_ = os.RemoveAll(tmpDir)
   113  		// Cleanup the destination in case of an error.
   114  		if err != nil {
   115  			_ = os.RemoveAll(destDir)
   116  		}
   117  	}()
   118  
   119  	// Open the archive for reading.
   120  	var archiveReader *zip.ReadCloser
   121  	archiveReader, err = zip.OpenReader(archiveFile)
   122  	if err != nil {
   123  		return fmt.Errorf("failed to open zip reader: %w", err)
   124  	}
   125  	defer func() {
   126  		_ = archiveReader.Close()
   127  	}()
   128  
   129  	// Save all files to the tmp dir.
   130  	for _, file := range archiveReader.File {
   131  		err = copyFromZipArchive(
   132  			file,
   133  			filepath.Join(tmpDir, filepath.FromSlash(file.Name)),
   134  		)
   135  		if err != nil {
   136  			return fmt.Errorf("failed to extract archive file %s: %w", file.Name, err)
   137  		}
   138  	}
   139  
   140  	// Make the final move.
   141  	err = os.Rename(tmpDir, destDir)
   142  	if err != nil {
   143  		return fmt.Errorf("failed to move the extracted archive from %s to %s: %w", tmpDir, destDir, err)
   144  	}
   145  
   146  	// Fix permissions on the destination dir.
   147  	err = res.registry.storageDir.EnsureAbsPath(destDir)
   148  	if err != nil {
   149  		return fmt.Errorf("failed to apply directory permissions on %s: %w", destDir, err)
   150  	}
   151  
   152  	log.Infof("%s: unpacked %s", res.registry.Name, res.SelectedVersion.versionedPath())
   153  	return nil
   154  }
   155  
   156  func copyFromZipArchive(archiveFile *zip.File, dstPath string) error {
   157  	// If file is a directory, create it and continue.
   158  	if archiveFile.FileInfo().IsDir() {
   159  		err := os.Mkdir(dstPath, archiveFile.Mode())
   160  		if err != nil {
   161  			return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
   162  		}
   163  		return nil
   164  	}
   165  
   166  	// Open archived file for reading.
   167  	fileReader, err := archiveFile.Open()
   168  	if err != nil {
   169  		return fmt.Errorf("failed to open file in archive: %w", err)
   170  	}
   171  	defer func() {
   172  		_ = fileReader.Close()
   173  	}()
   174  
   175  	// Open destination file for writing.
   176  	dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, archiveFile.Mode())
   177  	if err != nil {
   178  		return fmt.Errorf("failed to open destination file %s: %w", dstPath, err)
   179  	}
   180  	defer func() {
   181  		_ = dstFile.Close()
   182  	}()
   183  
   184  	// Copy full file from archive to dst.
   185  	if _, err := io.CopyN(dstFile, fileReader, MaxUnpackSize); err != nil {
   186  		// EOF is expected here as the archive is likely smaller
   187  		// thane MaxUnpackSize
   188  		if errors.Is(err, io.EOF) {
   189  			return nil
   190  		}
   191  		return err
   192  	}
   193  
   194  	return nil
   195  }