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 }