• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

devonfw / IDEasy / 12236799868

09 Dec 2024 01:37PM UTC coverage: 67.035% (+0.08%) from 66.953%
12236799868

push

github

web-flow
#799: fix zip extraction to preserve file attributes (#835)

2543 of 4142 branches covered (61.4%)

Branch coverage included in aggregate %.

6608 of 9509 relevant lines covered (69.49%)

3.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.52
cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java
1
package com.devonfw.tools.ide.io;
2

3
import java.io.BufferedOutputStream;
4
import java.io.FileInputStream;
5
import java.io.FileOutputStream;
6
import java.io.IOException;
7
import java.io.InputStream;
8
import java.io.OutputStream;
9
import java.net.URI;
10
import java.net.http.HttpClient;
11
import java.net.http.HttpClient.Redirect;
12
import java.net.http.HttpRequest;
13
import java.net.http.HttpResponse;
14
import java.nio.file.FileSystem;
15
import java.nio.file.FileSystemException;
16
import java.nio.file.FileSystems;
17
import java.nio.file.Files;
18
import java.nio.file.LinkOption;
19
import java.nio.file.NoSuchFileException;
20
import java.nio.file.Path;
21
import java.nio.file.attribute.BasicFileAttributes;
22
import java.nio.file.attribute.FileTime;
23
import java.nio.file.attribute.PosixFilePermission;
24
import java.nio.file.attribute.PosixFilePermissions;
25
import java.security.DigestInputStream;
26
import java.security.MessageDigest;
27
import java.security.NoSuchAlgorithmException;
28
import java.time.LocalDateTime;
29
import java.util.ArrayList;
30
import java.util.Iterator;
31
import java.util.List;
32
import java.util.Map;
33
import java.util.Set;
34
import java.util.function.Consumer;
35
import java.util.function.Function;
36
import java.util.function.Predicate;
37
import java.util.stream.Stream;
38

39
import org.apache.commons.compress.archivers.ArchiveEntry;
40
import org.apache.commons.compress.archivers.ArchiveInputStream;
41
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
42
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
43

44
import com.devonfw.tools.ide.cli.CliException;
45
import com.devonfw.tools.ide.cli.CliOfflineException;
46
import com.devonfw.tools.ide.context.IdeContext;
47
import com.devonfw.tools.ide.os.SystemInfoImpl;
48
import com.devonfw.tools.ide.process.ProcessContext;
49
import com.devonfw.tools.ide.url.model.file.UrlChecksum;
50
import com.devonfw.tools.ide.util.DateTimeUtil;
51
import com.devonfw.tools.ide.util.FilenameUtil;
52
import com.devonfw.tools.ide.util.HexUtil;
53

54
/**
55
 * Implementation of {@link FileAccess}.
56
 */
57
public class FileAccessImpl implements FileAccess {
58

59
  private static final String WINDOWS_FILE_LOCK_DOCUMENTATION_PAGE = "https://github.com/devonfw/IDEasy/blob/main/documentation/windows-file-lock.adoc";
60

61
  private static final String WINDOWS_FILE_LOCK_WARNING =
62
      "On Windows, file operations could fail due to file locks. Please ensure the files in the moved directory are not in use. For further details, see: \n"
63
          + WINDOWS_FILE_LOCK_DOCUMENTATION_PAGE;
64

65
  private static final Map<String, String> FS_ENV = Map.of("encoding", "UTF-8");
5✔
66

67
  private final IdeContext context;
68

69
  /**
70
   * The constructor.
71
   *
72
   * @param context the {@link IdeContext} to use.
73
   */
74
  public FileAccessImpl(IdeContext context) {
75

76
    super();
2✔
77
    this.context = context;
3✔
78
  }
1✔
79

80
  private HttpClient createHttpClient(String url) {
81

82
    HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS);
4✔
83
    return builder.build();
3✔
84
  }
85

86
  @Override
87
  public void download(String url, Path target) {
88

89
    this.context.info("Trying to download {} from {}", target.getFileName(), url);
15✔
90
    mkdirs(target.getParent());
4✔
91
    try {
92
      if (this.context.isOffline()) {
4!
93
        throw CliOfflineException.ofDownloadViaUrl(url);
×
94
      }
95
      if (url.startsWith("http")) {
4✔
96

97
        HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
7✔
98
        HttpClient client = createHttpClient(url);
4✔
99
        HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
5✔
100
        if (response.statusCode() == 200) {
4!
101
          downloadFileWithProgressBar(url, target, response);
5✔
102
        }
103
      } else if (url.startsWith("ftp") || url.startsWith("sftp")) {
9!
104
        throw new IllegalArgumentException("Unsupported download URL: " + url);
×
105
      } else {
106
        Path source = Path.of(url);
5✔
107
        if (isFile(source)) {
4!
108
          // network drive
109

110
          copyFileWithProgressBar(source, target);
5✔
111
        } else {
112
          throw new IllegalArgumentException("Download path does not point to a downloadable file: " + url);
×
113
        }
114
      }
115
    } catch (Exception e) {
×
116
      throw new IllegalStateException("Failed to download file from URL " + url + " to " + target, e);
×
117
    }
1✔
118
  }
1✔
119

120
  /**
121
   * Downloads a file while showing a {@link IdeProgressBar}.
122
   *
123
   * @param url the url to download.
124
   * @param target Path of the target directory.
125
   * @param response the {@link HttpResponse} to use.
126
   */
127
  private void downloadFileWithProgressBar(String url, Path target, HttpResponse<InputStream> response) {
128

129
    long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1);
7✔
130
    informAboutMissingContentLength(contentLength, url);
4✔
131

132
    byte[] data = new byte[1024];
3✔
133
    boolean fileComplete = false;
2✔
134
    int count;
135

136
    try (InputStream body = response.body();
4✔
137
        FileOutputStream fileOutput = new FileOutputStream(target.toFile());
6✔
138
        BufferedOutputStream bufferedOut = new BufferedOutputStream(fileOutput, data.length);
7✔
139
        IdeProgressBar pb = this.context.newProgressBarForDownload(contentLength)) {
5✔
140
      while (!fileComplete) {
2✔
141
        count = body.read(data);
4✔
142
        if (count <= 0) {
2✔
143
          fileComplete = true;
3✔
144
        } else {
145
          bufferedOut.write(data, 0, count);
5✔
146
          pb.stepBy(count);
5✔
147
        }
148
      }
149

150
    } catch (Exception e) {
×
151
      throw new RuntimeException(e);
×
152
    }
1✔
153
  }
1✔
154

155
  /**
156
   * Copies a file while displaying a progress bar.
157
   *
158
   * @param source Path of file to copy.
159
   * @param target Path of target directory.
160
   */
161
  private void copyFileWithProgressBar(Path source, Path target) throws IOException {
162

163
    try (InputStream in = new FileInputStream(source.toFile()); OutputStream out = new FileOutputStream(target.toFile())) {
12✔
164
      long size = getFileSize(source);
4✔
165
      byte[] buf = new byte[1024];
3✔
166
      try (IdeProgressBar pb = this.context.newProgressbarForCopying(size)) {
5✔
167
        int readBytes;
168
        while ((readBytes = in.read(buf)) > 0) {
6✔
169
          out.write(buf, 0, readBytes);
5✔
170
          if (size > 0) {
4!
171
            pb.stepBy(readBytes);
5✔
172
          }
173
        }
174
      } catch (Exception e) {
×
175
        throw new RuntimeException(e);
×
176
      }
1✔
177
    }
178
  }
1✔
179

180
  private void informAboutMissingContentLength(long contentLength, String url) {
181

182
    if (contentLength < 0) {
4✔
183
      this.context.warning("Content-Length was not provided by download from {}", url);
10✔
184
    }
185
  }
1✔
186

187
  @Override
188
  public void mkdirs(Path directory) {
189

190
    if (Files.isDirectory(directory)) {
5✔
191
      return;
1✔
192
    }
193
    this.context.trace("Creating directory {}", directory);
10✔
194
    try {
195
      Files.createDirectories(directory);
5✔
196
    } catch (IOException e) {
×
197
      throw new IllegalStateException("Failed to create directory " + directory, e);
×
198
    }
1✔
199
  }
1✔
200

201
  @Override
202
  public boolean isFile(Path file) {
203

204
    if (!Files.exists(file)) {
5!
205
      this.context.trace("File {} does not exist", file);
×
206
      return false;
×
207
    }
208
    if (Files.isDirectory(file)) {
5!
209
      this.context.trace("Path {} is a directory but a regular file was expected", file);
×
210
      return false;
×
211
    }
212
    return true;
2✔
213
  }
214

215
  @Override
216
  public boolean isExpectedFolder(Path folder) {
217

218
    if (Files.isDirectory(folder)) {
5✔
219
      return true;
2✔
220
    }
221
    this.context.warning("Expected folder was not found at {}", folder);
10✔
222
    return false;
2✔
223
  }
224

225
  @Override
226
  public String checksum(Path file) {
227

228
    try {
229
      MessageDigest md = MessageDigest.getInstance(UrlChecksum.HASH_ALGORITHM);
×
230
      byte[] buffer = new byte[1024];
×
231
      try (InputStream is = Files.newInputStream(file); DigestInputStream dis = new DigestInputStream(is, md)) {
×
232
        int read = 0;
×
233
        while (read >= 0) {
×
234
          read = dis.read(buffer);
×
235
        }
236
      } catch (Exception e) {
×
237
        throw new IllegalStateException("Failed to read and hash file " + file, e);
×
238
      }
×
239
      byte[] digestBytes = md.digest();
×
240
      return HexUtil.toHexString(digestBytes);
×
241
    } catch (NoSuchAlgorithmException e) {
×
242
      throw new IllegalStateException("No such hash algorithm " + UrlChecksum.HASH_ALGORITHM, e);
×
243
    }
244
  }
245

246
  private boolean isJunction(Path path) {
247

248
    if (!SystemInfoImpl.INSTANCE.isWindows()) {
3!
249
      return false;
2✔
250
    }
251

252
    try {
253
      BasicFileAttributes attr = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
×
254
      return attr.isOther() && attr.isDirectory();
×
255
    } catch (NoSuchFileException e) {
×
256
      return false; // file doesn't exist
×
257
    } catch (IOException e) {
×
258
      // errors in reading the attributes of the file
259
      throw new IllegalStateException("An unexpected error occurred whilst checking if the file: " + path + " is a junction", e);
×
260
    }
261
  }
262

263
  @Override
264
  public void backup(Path fileOrFolder) {
265

266
    if (Files.isSymbolicLink(fileOrFolder) || isJunction(fileOrFolder)) {
7!
267
      delete(fileOrFolder);
×
268
    } else {
269
      // fileOrFolder is a directory
270
      Path backupPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_BACKUPS);
8✔
271
      LocalDateTime now = LocalDateTime.now();
2✔
272
      String date = DateTimeUtil.formatDate(now);
3✔
273
      String time = DateTimeUtil.formatTime(now);
3✔
274
      Path backupDatePath = backupPath.resolve(date);
4✔
275
      mkdirs(backupDatePath);
3✔
276
      Path target = backupDatePath.resolve(fileOrFolder.getFileName().toString() + "_" + time);
8✔
277
      this.context.info("Creating backup by moving {} to {}", fileOrFolder, target);
14✔
278
      move(fileOrFolder, target);
4✔
279
    }
280
  }
1✔
281

282
  @Override
283
  public void move(Path source, Path targetDir) {
284

285
    this.context.trace("Moving {} to {}", source, targetDir);
14✔
286
    try {
287
      Files.move(source, targetDir);
6✔
288
    } catch (IOException e) {
×
289
      String fileType = Files.isSymbolicLink(source) ? "symlink" : isJunction(source) ? "junction" : Files.isDirectory(source) ? "directory" : "file";
×
290
      String message = "Failed to move " + fileType + ": " + source + " to " + targetDir + ".";
×
291
      if (this.context.getSystemInfo().isWindows()) {
×
292
        message = message + "\n" + WINDOWS_FILE_LOCK_WARNING;
×
293
      }
294
      throw new IllegalStateException(message, e);
×
295
    }
1✔
296
  }
1✔
297

298
  @Override
299
  public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) {
300

301
    if (mode != FileCopyMode.COPY_TREE_CONTENT) {
3✔
302
      // if we want to copy the file or folder "source" to the existing folder "target" in a shell this will copy
303
      // source into that folder so that we as a result have a copy in "target/source".
304
      // With Java NIO the raw copy method will fail as we cannot copy "source" to the path of the "target" folder.
305
      // For folders we want the same behavior as the linux "cp -r" command so that the "source" folder is copied
306
      // and not only its content what also makes it consistent with the move method that also behaves this way.
307
      // Therefore we need to add the filename (foldername) of "source" to the "target" path before.
308
      // For the rare cases, where we want to copy the content of a folder (cp -r source/* target) we support
309
      // it via the COPY_TREE_CONTENT mode.
310
      Path fileName = source.getFileName();
3✔
311
      if (fileName != null) { // if filename is null, we are copying the root of a (virtual filesystem)
2✔
312
        target = target.resolve(fileName.toString());
5✔
313
      }
314
    }
315
    boolean fileOnly = mode.isFileOnly();
3✔
316
    String operation = mode.getOperation();
3✔
317
    if (mode.isExtract()) {
3✔
318
      this.context.debug("Starting to {} to {}", operation, target);
15✔
319
    } else {
320
      if (fileOnly) {
2✔
321
        this.context.debug("Starting to {} file {} to {}", operation, source, target);
19✔
322
      } else {
323
        this.context.debug("Starting to {} {} recursively to {}", operation, source, target);
18✔
324
      }
325
    }
326
    if (fileOnly && Files.isDirectory(source)) {
7!
327
      throw new IllegalStateException("Expected file but found a directory to copy at " + source);
×
328
    }
329
    if (mode.isFailIfExists()) {
3✔
330
      if (Files.exists(target)) {
5!
331
        throw new IllegalStateException("Failed to " + operation + " " + source + " to already existing target " + target);
×
332
      }
333
    } else if (mode == FileCopyMode.COPY_TREE_OVERRIDE_TREE) {
3✔
334
      delete(target);
3✔
335
    }
336
    try {
337
      copyRecursive(source, target, mode, listener);
6✔
338
    } catch (IOException e) {
×
339
      throw new IllegalStateException("Failed to " + operation + " " + source + " to " + target, e);
×
340
    }
1✔
341
  }
1✔
342

343
  private void copyRecursive(Path source, Path target, FileCopyMode mode, PathCopyListener listener) throws IOException {
344

345
    if (Files.isDirectory(source)) {
5✔
346
      mkdirs(target);
3✔
347
      try (Stream<Path> childStream = Files.list(source)) {
3✔
348
        Iterator<Path> iterator = childStream.iterator();
3✔
349
        while (iterator.hasNext()) {
3✔
350
          Path child = iterator.next();
4✔
351
          copyRecursive(child, target.resolve(child.getFileName().toString()), mode, listener);
10✔
352
        }
1✔
353
      }
354
      listener.onCopy(source, target, true);
6✔
355
    } else if (Files.exists(source)) {
5!
356
      if (mode.isOverrideFile()) {
3✔
357
        delete(target);
3✔
358
      }
359
      this.context.trace("Starting to {} {} to {}", mode.getOperation(), source, target);
19✔
360
      Files.copy(source, target);
6✔
361
      listener.onCopy(source, target, false);
6✔
362
    } else {
363
      throw new IOException("Path " + source + " does not exist.");
×
364
    }
365
  }
1✔
366

367
  /**
368
   * Deletes the given {@link Path} if it is a symbolic link or a Windows junction. And throws an {@link IllegalStateException} if there is a file at the given
369
   * {@link Path} that is neither a symbolic link nor a Windows junction.
370
   *
371
   * @param path the {@link Path} to delete.
372
   * @throws IOException if the actual {@link Files#delete(Path) deletion} fails.
373
   */
374
  private void deleteLinkIfExists(Path path) throws IOException {
375

376
    boolean isJunction = isJunction(path); // since broken junctions are not detected by Files.exists()
4✔
377
    boolean isSymlink = Files.exists(path) && Files.isSymbolicLink(path);
12!
378

379
    assert !(isSymlink && isJunction);
5!
380

381
    if (isJunction || isSymlink) {
4!
382
      this.context.info("Deleting previous " + (isJunction ? "junction" : "symlink") + " at " + path);
8!
383
      Files.delete(path);
2✔
384
    }
385
  }
1✔
386

387
  /**
388
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
389
   * is applied to {@code source}.
390
   *
391
   * @param source the {@link Path} to adapt.
392
   * @param targetLink the {@link Path} used to calculate the relative path to the {@code source} if {@code relative} is set to {@code true}.
393
   * @param relative the {@code relative} flag.
394
   * @return the adapted {@link Path}.
395
   * @see FileAccessImpl#symlink(Path, Path, boolean)
396
   */
397
  private Path adaptPath(Path source, Path targetLink, boolean relative) throws IOException {
398

399
    if (source.isAbsolute()) {
3✔
400
      try {
401
        source = source.toRealPath(LinkOption.NOFOLLOW_LINKS); // to transform ../d1/../d2 to ../d2
9✔
402
      } catch (IOException e) {
×
403
        throw new IOException("Calling toRealPath() on the source (" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
404
      }
1✔
405
      if (relative) {
2✔
406
        source = targetLink.getParent().relativize(source);
5✔
407
        // to make relative links like this work: dir/link -> dir
408
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
409
      }
410
    } else { // source is relative
411
      if (relative) {
2✔
412
        // even though the source is already relative, toRealPath should be called to transform paths like
413
        // this ../d1/../d2 to ../d2
414
        source = targetLink.getParent().relativize(targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS));
14✔
415
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
416
      } else { // !relative
417
        try {
418
          source = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
11✔
419
        } catch (IOException e) {
×
420
          throw new IOException("Calling toRealPath() on " + targetLink + ".resolveSibling(" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
421
        }
1✔
422
      }
423
    }
424
    return source;
2✔
425
  }
426

427
  /**
428
   * Creates a Windows junction at {@code targetLink} pointing to {@code source}.
429
   *
430
   * @param source must be another Windows junction or a directory.
431
   * @param targetLink the location of the Windows junction.
432
   */
433
  private void createWindowsJunction(Path source, Path targetLink) {
434

435
    this.context.trace("Creating a Windows junction at " + targetLink + " with " + source + " as source.");
×
436
    Path fallbackPath;
437
    if (!source.isAbsolute()) {
×
438
      this.context.warning("You are on Windows and you do not have permissions to create symbolic links. Junctions are used as an "
×
439
          + "alternative, however, these can not point to relative paths. So the source (" + source + ") is interpreted as an absolute path.");
440
      try {
441
        fallbackPath = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
×
442
      } catch (IOException e) {
×
443
        throw new IllegalStateException(
×
444
            "Since Windows junctions are used, the source must be an absolute path. The transformation of the passed " + "source (" + source
445
                + ") to an absolute path failed.", e);
446
      }
×
447

448
    } else {
449
      fallbackPath = source;
×
450
    }
451
    if (!Files.isDirectory(fallbackPath)) { // if source is a junction. This returns true as well.
×
452
      throw new IllegalStateException(
×
453
          "These junctions can only point to directories or other junctions. Please make sure that the source (" + fallbackPath + ") is one of these.");
454
    }
455
    this.context.newProcess().executable("cmd").addArgs("/c", "mklink", "/d", "/j", targetLink.toString(), fallbackPath.toString()).run();
×
456
  }
×
457

458
  @Override
459
  public void symlink(Path source, Path targetLink, boolean relative) {
460

461
    Path adaptedSource = null;
2✔
462
    try {
463
      adaptedSource = adaptPath(source, targetLink, relative);
6✔
464
    } catch (IOException e) {
×
465
      throw new IllegalStateException("Failed to adapt source for source (" + source + ") target (" + targetLink + ") and relative (" + relative + ")", e);
×
466
    }
1✔
467
    this.context.trace("Creating {} symbolic link {} pointing to {}", adaptedSource.isAbsolute() ? "" : "relative", targetLink, adaptedSource);
23✔
468

469
    try {
470
      deleteLinkIfExists(targetLink);
3✔
471
    } catch (IOException e) {
×
472
      throw new IllegalStateException("Failed to delete previous symlink or Windows junction at " + targetLink, e);
×
473
    }
1✔
474

475
    try {
476
      Files.createSymbolicLink(targetLink, adaptedSource);
6✔
477
    } catch (FileSystemException e) {
×
478
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
479
        this.context.info("Due to lack of permissions, Microsoft's mklink with junction had to be used to create "
×
480
            + "a Symlink. See https://github.com/devonfw/IDEasy/blob/main/documentation/symlinks.asciidoc for " + "further details. Error was: "
481
            + e.getMessage());
×
482
        createWindowsJunction(adaptedSource, targetLink);
×
483
      } else {
484
        throw new RuntimeException(e);
×
485
      }
486
    } catch (IOException e) {
×
487
      throw new IllegalStateException(
×
488
          "Failed to create a " + (adaptedSource.isAbsolute() ? "" : "relative") + "symbolic link " + targetLink + " pointing to " + source, e);
×
489
    }
1✔
490
  }
1✔
491

492
  @Override
493
  public Path toRealPath(Path path) {
494

495
    try {
496
      Path realPath = path.toRealPath();
×
497
      if (!realPath.equals(path)) {
×
498
        this.context.trace("Resolved path {} to {}", path, realPath);
×
499
      }
500
      return realPath;
×
501
    } catch (IOException e) {
×
502
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
503
    }
504
  }
505

506
  @Override
507
  public Path createTempDir(String name) {
508

509
    try {
510
      Path tmp = this.context.getTempPath();
4✔
511
      Path tempDir = tmp.resolve(name);
4✔
512
      int tries = 1;
2✔
513
      while (Files.exists(tempDir)) {
5!
514
        long id = System.nanoTime() & 0xFFFF;
×
515
        tempDir = tmp.resolve(name + "-" + id);
×
516
        tries++;
×
517
        if (tries > 200) {
×
518
          throw new IOException("Unable to create unique name!");
×
519
        }
520
      }
×
521
      return Files.createDirectory(tempDir);
5✔
522
    } catch (IOException e) {
×
523
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
524
    }
525
  }
526

527
  @Override
528
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
529

530
    if (Files.isDirectory(archiveFile)) {
5✔
531
      // TODO: check this case
532
      Path properInstallDir = archiveFile; // getProperInstallationSubDirOf(archiveFile, archiveFile);
2✔
533
      this.context.warning("Found directory for download at {} hence copying without extraction!", archiveFile);
10✔
534
      copy(properInstallDir, targetDir, FileCopyMode.COPY_TREE_CONTENT);
5✔
535
      postExtractHook(postExtractHook, targetDir);
4✔
536
      return;
1✔
537
    } else if (!extract) {
2!
538
      mkdirs(targetDir);
×
539
      move(archiveFile, targetDir.resolve(archiveFile.getFileName()));
×
540
      return;
×
541
    }
542
    Path tmpDir = createTempDir("extract-" + archiveFile.getFileName());
6✔
543
    this.context.trace("Trying to extract the downloaded file {} to {} and move it to {}.", archiveFile, tmpDir, targetDir);
18✔
544
    String filename = archiveFile.getFileName().toString();
4✔
545
    TarCompression tarCompression = TarCompression.of(filename);
3✔
546
    if (tarCompression != null) {
2✔
547
      extractTar(archiveFile, tmpDir, tarCompression);
6✔
548
    } else {
549
      String extension = FilenameUtil.getExtension(filename);
3✔
550
      if (extension == null) {
2!
551
        throw new IllegalStateException("Unknown archive format without extension - can not extract " + archiveFile);
×
552
      } else {
553
        this.context.trace("Determined file extension {}", extension);
10✔
554
      }
555
      switch (extension) {
8!
556
        case "zip" -> extractZip(archiveFile, tmpDir);
×
557
        case "jar" -> extractJar(archiveFile, tmpDir);
5✔
558
        case "dmg" -> extractDmg(archiveFile, tmpDir);
×
559
        case "msi" -> extractMsi(archiveFile, tmpDir);
×
560
        case "pkg" -> extractPkg(archiveFile, tmpDir);
×
561
        default -> throw new IllegalStateException("Unknown archive format " + extension + ". Can not extract " + archiveFile);
×
562
      }
563
    }
564
    Path properInstallDir = getProperInstallationSubDirOf(tmpDir, archiveFile);
5✔
565
    postExtractHook(postExtractHook, properInstallDir);
4✔
566
    move(properInstallDir, targetDir);
4✔
567
    delete(tmpDir);
3✔
568
  }
1✔
569

570
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
571

572
    if (postExtractHook != null) {
2✔
573
      postExtractHook.accept(properInstallDir);
3✔
574
    }
575
  }
1✔
576

577
  /**
578
   * @param path the {@link Path} to start the recursive search from.
579
   * @return the deepest subdir {@code s} of the passed path such that all directories between {@code s} and the passed path (including {@code s}) are the sole
580
   *     item in their respective directory and {@code s} is not named "bin".
581
   */
582
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
583

584
    try (Stream<Path> stream = Files.list(path)) {
3✔
585
      Path[] subFiles = stream.toArray(Path[]::new);
8✔
586
      if (subFiles.length == 0) {
3!
587
        throw new CliException("The downloaded package " + archiveFile + " seems to be empty as you can check in the extracted folder " + path);
×
588
      } else if (subFiles.length == 1) {
4✔
589
        String filename = subFiles[0].getFileName().toString();
6✔
590
        if (!filename.equals(IdeContext.FOLDER_BIN) && !filename.equals(IdeContext.FOLDER_CONTENTS) && !filename.endsWith(".app") && Files.isDirectory(
19!
591
            subFiles[0])) {
592
          return getProperInstallationSubDirOf(subFiles[0], archiveFile);
9✔
593
        }
594
      }
595
      return path;
4✔
596
    } catch (IOException e) {
4!
597
      throw new IllegalStateException("Failed to get sub-files of " + path);
×
598
    }
599
  }
600

601
  @Override
602
  public void extractZip(Path file, Path targetDir) {
603

604
    this.context.info("Extracting ZIP file {} to {}", file, targetDir);
14✔
605
    URI uri = URI.create("jar:" + file.toUri());
5✔
606
    try (FileSystem fs = FileSystems.newFileSystem(uri, FS_ENV)) {
4✔
607
      long size = 0;
2✔
608
      for (Path root : fs.getRootDirectories()) {
11✔
609
        size += getFileSizeRecursive(root);
6✔
610
      }
1✔
611
      try (IdeProgressBar bp = this.context.newProgressbarForExtracting(size)) {
5✔
612
        PathCopyListener listener = (source, target, directory) -> {
4✔
613
          if (directory) {
2✔
614
            return;
1✔
615
          }
616
          if (!context.getSystemInfo().isWindows()) {
5✔
617
            try {
618
              Object attribute = Files.getAttribute(source, "zip:permissions");
6✔
619
              if (attribute instanceof Set<?> permissionSet) {
6✔
620
                Files.setPosixFilePermissions(target, (Set<PosixFilePermission>) permissionSet);
4✔
621
              }
622
            } catch (Exception e) {
×
623
              context.error(e, "Failed to transfer zip permissions for {}", target);
×
624
            }
1✔
625
          }
626
          bp.stepBy(getFileSize(target));
5✔
627
        };
1✔
628
        for (Path root : fs.getRootDirectories()) {
11✔
629
          copy(root, targetDir, FileCopyMode.EXTRACT, listener);
6✔
630
        }
1✔
631
      }
632
    } catch (IOException e) {
×
633
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
634
    }
1✔
635
  }
1✔
636

637
  @Override
638
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
639

640
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
641
  }
1✔
642

643
  @Override
644
  public void extractJar(Path file, Path targetDir) {
645

646
    extractZip(file, targetDir);
4✔
647
  }
1✔
648

649
  /**
650
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
651
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
652
   */
653
  public static String generatePermissionString(int permissions) {
654

655
    // Ensure that only the last 9 bits are considered
656
    permissions &= 0b111111111;
4✔
657

658
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
659
    for (int i = 0; i < 9; i++) {
7✔
660
      int mask = 1 << i;
4✔
661
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
662
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
663
    }
664

665
    return permissionStringBuilder.toString();
3✔
666
  }
667

668
  private void extractArchive(Path file, Path targetDir, Function<InputStream, ArchiveInputStream<?>> unpacker) {
669

670
    this.context.info("Extracting TAR file {} to {}", file, targetDir);
14✔
671
    try (InputStream is = Files.newInputStream(file);
5✔
672
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
673
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
674

675
      ArchiveEntry entry = ais.getNextEntry();
3✔
676
      boolean isTar = ais instanceof TarArchiveInputStream;
3✔
677
      while (entry != null) {
2✔
678
        String permissionStr = null;
2✔
679
        if (isTar) {
2!
680
          int tarMode = ((TarArchiveEntry) entry).getMode();
4✔
681
          permissionStr = generatePermissionString(tarMode);
3✔
682
        }
683
        Path entryName = Path.of(entry.getName());
6✔
684
        Path entryPath = targetDir.resolve(entryName).toAbsolutePath();
5✔
685
        if (!entryPath.startsWith(targetDir)) {
4!
686
          throw new IOException("Preventing path traversal attack from " + entryName + " to " + entryPath);
×
687
        }
688
        if (entry.isDirectory()) {
3✔
689
          mkdirs(entryPath);
4✔
690
        } else {
691
          // ensure the file can also be created if directory entry was missing or out of order...
692
          mkdirs(entryPath.getParent());
4✔
693
          Files.copy(ais, entryPath);
6✔
694
        }
695
        if (isTar && !this.context.getSystemInfo().isWindows()) {
7!
696
          Set<PosixFilePermission> permissions = PosixFilePermissions.fromString(permissionStr);
3✔
697
          Files.setPosixFilePermissions(entryPath, permissions);
4✔
698
        }
699
        pb.stepBy(entry.getSize());
4✔
700
        entry = ais.getNextEntry();
3✔
701
      }
1✔
702
    } catch (IOException e) {
×
703
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
704
    }
1✔
705
  }
1✔
706

707
  @Override
708
  public void extractDmg(Path file, Path targetDir) {
709

710
    this.context.info("Extracting DMG file {} to {}", file, targetDir);
×
711
    assert this.context.getSystemInfo().isMac();
×
712

713
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
714
    mkdirs(mountPath);
×
715
    ProcessContext pc = this.context.newProcess();
×
716
    pc.executable("hdiutil");
×
717
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
718
    pc.run();
×
719
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
720
    if (appPath == null) {
×
721
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
722
    }
723

724
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
725
    pc.addArgs("detach", "-force", mountPath);
×
726
    pc.run();
×
727
  }
×
728

729
  @Override
730
  public void extractMsi(Path file, Path targetDir) {
731

732
    this.context.info("Extracting MSI file {} to {}", file, targetDir);
×
733
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
734
    // msiexec also creates a copy of the MSI
735
    Path msiCopy = targetDir.resolve(file.getFileName());
×
736
    delete(msiCopy);
×
737
  }
×
738

739
  @Override
740
  public void extractPkg(Path file, Path targetDir) {
741

742
    this.context.info("Extracting PKG file {} to {}", file, targetDir);
×
743
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
744
    ProcessContext pc = this.context.newProcess();
×
745
    // we might also be able to use cpio from commons-compression instead of external xar...
746
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
747
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
748
    extractTar(contentPath, targetDir, TarCompression.GZ);
×
749
    delete(tmpDirPkg);
×
750
  }
×
751

752
  @Override
753
  public void delete(Path path) {
754

755
    if (!Files.exists(path)) {
5✔
756
      this.context.trace("Deleting {} skipped as the path does not exist.", path);
10✔
757
      return;
1✔
758
    }
759
    this.context.debug("Deleting {} ...", path);
10✔
760
    try {
761
      if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
762
        Files.delete(path);
×
763
      } else {
764
        deleteRecursive(path);
3✔
765
      }
766
    } catch (IOException e) {
×
767
      throw new IllegalStateException("Failed to delete " + path, e);
×
768
    }
1✔
769
  }
1✔
770

771
  private void deleteRecursive(Path path) throws IOException {
772

773
    if (Files.isDirectory(path)) {
5✔
774
      try (Stream<Path> childStream = Files.list(path)) {
3✔
775
        Iterator<Path> iterator = childStream.iterator();
3✔
776
        while (iterator.hasNext()) {
3✔
777
          Path child = iterator.next();
4✔
778
          deleteRecursive(child);
3✔
779
        }
1✔
780
      }
781
    }
782
    this.context.trace("Deleting {} ...", path);
10✔
783
    Files.delete(path);
2✔
784
  }
1✔
785

786
  @Override
787
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
788

789
    try {
790
      if (!Files.isDirectory(dir)) {
5!
791
        return null;
×
792
      }
793
      return findFirstRecursive(dir, filter, recursive);
6✔
794
    } catch (IOException e) {
×
795
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
796
    }
797
  }
798

799
  private Path findFirstRecursive(Path dir, Predicate<Path> filter, boolean recursive) throws IOException {
800

801
    List<Path> folders = null;
2✔
802
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
803
      Iterator<Path> iterator = childStream.iterator();
3✔
804
      while (iterator.hasNext()) {
3✔
805
        Path child = iterator.next();
4✔
806
        if (filter.test(child)) {
4✔
807
          return child;
4✔
808
        } else if (recursive && Files.isDirectory(child)) {
2!
809
          if (folders == null) {
×
810
            folders = new ArrayList<>();
×
811
          }
812
          folders.add(child);
×
813
        }
814
      }
1✔
815
    }
4!
816
    if (folders != null) {
2!
817
      for (Path child : folders) {
×
818
        Path match = findFirstRecursive(child, filter, recursive);
×
819
        if (match != null) {
×
820
          return match;
×
821
        }
822
      }
×
823
    }
824
    return null;
2✔
825
  }
826

827
  @Override
828
  public List<Path> listChildren(Path dir, Predicate<Path> filter) {
829

830
    if (!Files.isDirectory(dir)) {
5✔
831
      return List.of();
2✔
832
    }
833
    List<Path> children = new ArrayList<>();
4✔
834
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
835
      Iterator<Path> iterator = childStream.iterator();
3✔
836
      while (iterator.hasNext()) {
3✔
837
        Path child = iterator.next();
4✔
838
        if (filter.test(child)) {
4!
839
          this.context.trace("Accepted file {}", child);
10✔
840
          children.add(child);
5✔
841
        } else {
842
          this.context.trace("Ignoring file {} according to filter", child);
×
843
        }
844
      }
1✔
845
    } catch (IOException e) {
×
846
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
847
    }
1✔
848
    return children;
2✔
849
  }
850

851
  @Override
852
  public boolean isEmptyDir(Path dir) {
853

854
    return listChildren(dir, f -> true).isEmpty();
8✔
855
  }
856

857
  private long getFileSize(Path file) {
858

859
    try {
860
      return Files.size(file);
3✔
861
    } catch (IOException e) {
×
862
      this.context.warning(e.getMessage(), e);
×
863
      return 0;
×
864
    }
865
  }
866

867
  private long getFileSizeRecursive(Path path) {
868

869
    long size = 0;
2✔
870
    if (Files.isDirectory(path)) {
5✔
871
      try (Stream<Path> childStream = Files.list(path)) {
3✔
872
        Iterator<Path> iterator = childStream.iterator();
3✔
873
        while (iterator.hasNext()) {
3✔
874
          Path child = iterator.next();
4✔
875
          size += getFileSizeRecursive(child);
6✔
876
        }
1✔
877
      } catch (IOException e) {
×
878
        throw new RuntimeException("Failed to iterate children of folder " + path, e);
×
879
      }
1✔
880
    } else {
881
      size += getFileSize(path);
6✔
882
    }
883
    return size;
2✔
884
  }
885

886
  @Override
887
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
888

889
    for (Path dir : searchDirs) {
10!
890
      Path filePath = dir.resolve(fileName);
4✔
891
      try {
892
        if (Files.exists(filePath)) {
5!
893
          return filePath;
2✔
894
        }
895
      } catch (Exception e) {
×
896
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
897
      }
×
898
    }
×
899
    return null;
×
900
  }
901

902
  @Override
903
  public void makeExecutable(Path filePath) {
904

905
    if (Files.exists(filePath)) {
×
906
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
907
        this.context.trace("Windows does not have executable flags hence omitting for file {}", filePath);
×
908
        return;
×
909
      }
910
      try {
911
        // Read the current file permissions
912
        Set<PosixFilePermission> perms = Files.getPosixFilePermissions(filePath);
×
913

914
        // Add execute permission for all users
915
        boolean update = false;
×
916
        update |= perms.add(PosixFilePermission.OWNER_EXECUTE);
×
917
        update |= perms.add(PosixFilePermission.GROUP_EXECUTE);
×
918
        update |= perms.add(PosixFilePermission.OTHERS_EXECUTE);
×
919

920
        if (update) {
×
921
          this.context.debug("Setting executable flags for file {}", filePath);
×
922
          // Set the new permissions
923
          Files.setPosixFilePermissions(filePath, perms);
×
924
        } else {
925
          this.context.trace("Executable flags already present so no need to set them for file {}", filePath);
×
926
        }
927
      } catch (IOException e) {
×
928
        throw new RuntimeException(e);
×
929
      }
×
930
    } else {
931
      this.context.warning("Cannot set executable flag on file that does not exist: {}", filePath);
×
932
    }
933
  }
×
934

935
  @Override
936
  public void touch(Path filePath) {
937

938
    if (Files.exists(filePath)) {
5✔
939
      try {
940
        Files.setLastModifiedTime(filePath, FileTime.fromMillis(System.currentTimeMillis()));
5✔
941
      } catch (IOException e) {
×
942
        throw new IllegalStateException("Could not update modification-time of " + filePath, e);
×
943
      }
1✔
944
    } else {
945
      try {
946
        Files.createFile(filePath);
5✔
947
      } catch (IOException e) {
1✔
948
        throw new IllegalStateException("Could not create empty file " + filePath, e);
7✔
949
      }
1✔
950
    }
951
  }
1✔
952
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc