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 }