package updater import ( "archive/zip" "compress/gzip" "errors" "fmt" "io" "io/fs" "os" "path" "path/filepath" "strings" "github.com/hashicorp/go-multierror" "github.com/safing/portbase/log" "github.com/safing/portbase/utils" ) // MaxUnpackSize specifies the maximum size that will be unpacked. const MaxUnpackSize = 1000000000 // 1GB // UnpackGZIP unpacks a GZIP compressed reader r // and returns a new reader. It's suitable to be // used with registry.GetPackedFile. func UnpackGZIP(r io.Reader) (io.Reader, error) { return gzip.NewReader(r) } // UnpackResources unpacks all resources defined in the AutoUnpack list. func (reg *ResourceRegistry) UnpackResources() error { reg.RLock() defer reg.RUnlock() var multierr *multierror.Error for _, res := range reg.resources { if utils.StringInSlice(reg.AutoUnpack, res.Identifier) { err := res.UnpackArchive() if err != nil { multierr = multierror.Append( multierr, fmt.Errorf("%s: %w", res.Identifier, err), ) } } } return multierr.ErrorOrNil() } const ( zipSuffix = ".zip" ) // UnpackArchive unpacks the archive the resource refers to. The contents are // unpacked into a directory with the same name as the file, excluding the // suffix. If the destination folder already exists, it is assumed that the // contents have already been correctly unpacked. func (res *Resource) UnpackArchive() error { res.Lock() defer res.Unlock() // Only unpack selected versions. if res.SelectedVersion == nil { return nil } switch { case strings.HasSuffix(res.Identifier, zipSuffix): return res.unpackZipArchive() default: return fmt.Errorf("unsupported file type for unpacking") } } func (res *Resource) unpackZipArchive() error { // Get file and directory paths. archiveFile := res.SelectedVersion.storagePath() destDir := strings.TrimSuffix(archiveFile, zipSuffix) tmpDir := filepath.Join( res.registry.tmpDir.Path, filepath.FromSlash(strings.TrimSuffix( path.Base(res.SelectedVersion.versionedPath()), zipSuffix, )), ) // Check status of destination. dstStat, err := os.Stat(destDir) switch { case errors.Is(err, fs.ErrNotExist): // The destination does not exist, continue with unpacking. case err != nil: return fmt.Errorf("cannot access destination for unpacking: %w", err) case !dstStat.IsDir(): return fmt.Errorf("destination for unpacking is blocked by file: %s", dstStat.Name()) default: // Archive already seems to be unpacked. return nil } // Create the tmp directory for unpacking. err = res.registry.tmpDir.EnsureAbsPath(tmpDir) if err != nil { return fmt.Errorf("failed to create tmp dir for unpacking: %w", err) } // Defer clean up of directories. defer func() { // Always clean up the tmp dir. _ = os.RemoveAll(tmpDir) // Cleanup the destination in case of an error. if err != nil { _ = os.RemoveAll(destDir) } }() // Open the archive for reading. var archiveReader *zip.ReadCloser archiveReader, err = zip.OpenReader(archiveFile) if err != nil { return fmt.Errorf("failed to open zip reader: %w", err) } defer func() { _ = archiveReader.Close() }() // Save all files to the tmp dir. for _, file := range archiveReader.File { err = copyFromZipArchive( file, filepath.Join(tmpDir, filepath.FromSlash(file.Name)), ) if err != nil { return fmt.Errorf("failed to extract archive file %s: %w", file.Name, err) } } // Make the final move. err = os.Rename(tmpDir, destDir) if err != nil { return fmt.Errorf("failed to move the extracted archive from %s to %s: %w", tmpDir, destDir, err) } // Fix permissions on the destination dir. err = res.registry.storageDir.EnsureAbsPath(destDir) if err != nil { return fmt.Errorf("failed to apply directory permissions on %s: %w", destDir, err) } log.Infof("%s: unpacked %s", res.registry.Name, res.SelectedVersion.versionedPath()) return nil } func copyFromZipArchive(archiveFile *zip.File, dstPath string) error { // If file is a directory, create it and continue. if archiveFile.FileInfo().IsDir() { err := os.Mkdir(dstPath, archiveFile.Mode()) if err != nil { return fmt.Errorf("failed to create directory %s: %w", dstPath, err) } return nil } // Open archived file for reading. fileReader, err := archiveFile.Open() if err != nil { return fmt.Errorf("failed to open file in archive: %w", err) } defer func() { _ = fileReader.Close() }() // Open destination file for writing. dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, archiveFile.Mode()) if err != nil { return fmt.Errorf("failed to open destination file %s: %w", dstPath, err) } defer func() { _ = dstFile.Close() }() // Copy full file from archive to dst. if _, err := io.CopyN(dstFile, fileReader, MaxUnpackSize); err != nil { // EOF is expected here as the archive is likely smaller // thane MaxUnpackSize if errors.Is(err, io.EOF) { return nil } return err } return nil }