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

devonfw / IDEasy / 10162545933

30 Jul 2024 12:41PM UTC coverage: 61.685% (+0.5%) from 61.217%
10162545933

push

github

web-flow
#433: Implement Intellij plugins installation (#490)

2040 of 3641 branches covered (56.03%)

Branch coverage included in aggregate %.

5419 of 8451 relevant lines covered (64.12%)

2.82 hits per line

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

57.75
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.InetSocketAddress;
10
import java.net.Proxy;
11
import java.net.ProxySelector;
12
import java.net.URI;
13
import java.net.http.HttpClient;
14
import java.net.http.HttpClient.Redirect;
15
import java.net.http.HttpRequest;
16
import java.net.http.HttpResponse;
17
import java.nio.file.FileSystemException;
18
import java.nio.file.Files;
19
import java.nio.file.LinkOption;
20
import java.nio.file.NoSuchFileException;
21
import java.nio.file.Path;
22
import java.nio.file.attribute.BasicFileAttributes;
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.Set;
33
import java.util.function.Consumer;
34
import java.util.function.Function;
35
import java.util.function.Predicate;
36
import java.util.jar.JarEntry;
37
import java.util.jar.JarInputStream;
38
import java.util.stream.Stream;
39

40
import org.apache.commons.compress.archivers.ArchiveEntry;
41
import org.apache.commons.compress.archivers.ArchiveInputStream;
42
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
43
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
44
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
45

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

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

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

62
  private static final String WINDOWS_FILE_LOCK_WARNING =
63
      "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"
64
          + WINDOWS_FILE_LOCK_DOCUMENTATION_PAGE;
65

66
  private final IdeContext context;
67

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

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

79
  private HttpClient createHttpClient(String url) {
80

81
    HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS);
4✔
82
    Proxy proxy = this.context.getProxyContext().getProxy(url);
6✔
83
    if (proxy != Proxy.NO_PROXY) {
3!
84
      this.context.info("Downloading through proxy: " + proxy);
×
85
      InetSocketAddress proxyAddress = (InetSocketAddress) proxy.address();
×
86
      builder.proxy(ProxySelector.of(proxyAddress));
×
87
    }
88
    return builder.build();
3✔
89
  }
90

91
  @Override
92
  public void download(String url, Path target) {
93

94
    this.context.info("Trying to download {} from {}", target.getFileName(), url);
15✔
95
    mkdirs(target.getParent());
4✔
96
    try {
97
      if (url.startsWith("http")) {
4!
98

99
        HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
7✔
100
        HttpClient client = createHttpClient(url);
4✔
101
        HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
5✔
102

103
        if (response.statusCode() == 200) {
4!
104
          downloadFileWithProgressBar(url, target, response);
5✔
105
        }
106
      } else if (url.startsWith("ftp") || url.startsWith("sftp")) {
1!
107
        throw new IllegalArgumentException("Unsupported download URL: " + url);
×
108
      } else {
109
        Path source = Path.of(url);
×
110
        if (isFile(source)) {
×
111
          // network drive
112
          copyFileWithProgressBar(source, target);
×
113
        } else {
114
          throw new IllegalArgumentException("Download path does not point to a downloadable file: " + url);
×
115
        }
116
      }
117
    } catch (Exception e) {
×
118
      throw new IllegalStateException("Failed to download file from URL " + url + " to " + target, e);
×
119
    }
1✔
120
  }
1✔
121

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

131
    long contentLength = response.headers().firstValueAsLong("content-length").orElse(0);
7✔
132
    if (contentLength == 0) {
4!
133
      this.context.warning("Content-Length was not provided by download source : {} using fallback for the progress bar which will be inaccurate.", url);
×
134
      contentLength = 10000000;
×
135
    }
136

137
    byte[] data = new byte[1024];
3✔
138
    boolean fileComplete = false;
2✔
139
    int count;
140

141
    try (InputStream body = response.body();
4✔
142
        FileOutputStream fileOutput = new FileOutputStream(target.toFile());
6✔
143
        BufferedOutputStream bufferedOut = new BufferedOutputStream(fileOutput, data.length);
7✔
144
        IdeProgressBar pb = this.context.prepareProgressBar("Downloading", contentLength)) {
6✔
145
      while (!fileComplete) {
2✔
146
        count = body.read(data);
4✔
147
        if (count <= 0) {
2✔
148
          fileComplete = true;
3✔
149
        } else {
150
          bufferedOut.write(data, 0, count);
5✔
151
          pb.stepBy(count);
5✔
152
        }
153
      }
154
    } catch (Exception e) {
×
155
      throw new RuntimeException(e);
×
156
    }
1✔
157
  }
1✔
158

159
  /**
160
   * Copies a file while displaying a progress bar
161
   *
162
   * @param source Path of file to copy
163
   * @param target Path of target directory
164
   */
165
  private void copyFileWithProgressBar(Path source, Path target) throws IOException {
166

167
    try (InputStream in = new FileInputStream(source.toFile()); OutputStream out = new FileOutputStream(target.toFile())) {
×
168

169
      long size = source.toFile().length();
×
170
      byte[] buf = new byte[1024];
×
171
      int readBytes;
172

173
      try (IdeProgressBar pb = this.context.prepareProgressBar("Copying", size)) {
×
174
        while ((readBytes = in.read(buf)) > 0) {
×
175
          out.write(buf, 0, readBytes);
×
176
          pb.stepByOne();
×
177
        }
178
      } catch (Exception e) {
×
179
        throw new RuntimeException(e);
×
180
      }
×
181
    }
182
  }
×
183

184
  @Override
185
  public void mkdirs(Path directory) {
186

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

198
  @Override
199
  public boolean isFile(Path file) {
200

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

212
  @Override
213
  public boolean isExpectedFolder(Path folder) {
214

215
    if (Files.isDirectory(folder)) {
5!
216
      return true;
×
217
    }
218
    this.context.warning("Expected folder was not found at {}", folder);
10✔
219
    return false;
2✔
220
  }
221

222
  @Override
223
  public String checksum(Path file) {
224

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

244
  private boolean isJunction(Path path) {
245

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

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

261
  @Override
262
  public void backup(Path fileOrFolder) {
263

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

280
  @Override
281
  public void move(Path source, Path targetDir) {
282

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

296
  @Override
297
  public void copy(Path source, Path target, FileCopyMode mode) {
298

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

333
  private void copyRecursive(Path source, Path target, FileCopyMode mode) throws IOException {
334

335
    if (Files.isDirectory(source)) {
5✔
336
      mkdirs(target);
3✔
337
      try (Stream<Path> childStream = Files.list(source)) {
4✔
338
        Iterator<Path> iterator = childStream.iterator();
3✔
339
        while (iterator.hasNext()) {
3✔
340
          Path child = iterator.next();
4✔
341
          copyRecursive(child, target.resolve(child.getFileName()), mode);
8✔
342
        }
1✔
343
      }
344
    } else if (Files.exists(source)) {
5!
345
      if (mode.isOverrideFile()) {
3✔
346
        delete(target);
3✔
347
      }
348
      this.context.trace("Copying {} to {}", source, target);
14✔
349
      Files.copy(source, target);
7✔
350
    } else {
351
      throw new IOException("Path " + source + " does not exist.");
×
352
    }
353
  }
1✔
354

355
  /**
356
   * 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
357
   * {@link Path} that is neither a symbolic link nor a Windows junction.
358
   *
359
   * @param path the {@link Path} to delete.
360
   * @throws IOException if the actual {@link Files#delete(Path) deletion} fails.
361
   */
362
  private void deleteLinkIfExists(Path path) throws IOException {
363

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

367
    assert !(isSymlink && isJunction);
5!
368

369
    if (isJunction || isSymlink) {
4!
370
      this.context.info("Deleting previous " + (isJunction ? "junction" : "symlink") + " at " + path);
8!
371
      Files.delete(path);
2✔
372
    }
373
  }
1✔
374

375
  /**
376
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
377
   * is applied to {@code source}.
378
   *
379
   * @param source the {@link Path} to adapt.
380
   * @param targetLink the {@link Path} used to calculate the relative path to the {@code source} if {@code relative} is set to {@code true}.
381
   * @param relative the {@code relative} flag.
382
   * @return the adapted {@link Path}.
383
   * @see FileAccessImpl#symlink(Path, Path, boolean)
384
   */
385
  private Path adaptPath(Path source, Path targetLink, boolean relative) throws IOException {
386

387
    if (source.isAbsolute()) {
3✔
388
      try {
389
        source = source.toRealPath(LinkOption.NOFOLLOW_LINKS); // to transform ../d1/../d2 to ../d2
9✔
390
      } catch (IOException e) {
×
391
        throw new IOException("Calling toRealPath() on the source (" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
392
      }
1✔
393
      if (relative) {
2✔
394
        source = targetLink.getParent().relativize(source);
5✔
395
        // to make relative links like this work: dir/link -> dir
396
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
397
      }
398
    } else { // source is relative
399
      if (relative) {
2✔
400
        // even though the source is already relative, toRealPath should be called to transform paths like
401
        // this ../d1/../d2 to ../d2
402
        source = targetLink.getParent().relativize(targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS));
14✔
403
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
404
      } else { // !relative
405
        try {
406
          source = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
11✔
407
        } catch (IOException e) {
×
408
          throw new IOException("Calling toRealPath() on " + targetLink + ".resolveSibling(" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
409
        }
1✔
410
      }
411
    }
412
    return source;
2✔
413
  }
414

415
  /**
416
   * Creates a Windows junction at {@code targetLink} pointing to {@code source}.
417
   *
418
   * @param source must be another Windows junction or a directory.
419
   * @param targetLink the location of the Windows junction.
420
   */
421
  private void createWindowsJunction(Path source, Path targetLink) {
422

423
    this.context.trace("Creating a Windows junction at " + targetLink + " with " + source + " as source.");
×
424
    Path fallbackPath;
425
    if (!source.isAbsolute()) {
×
426
      this.context.warning("You are on Windows and you do not have permissions to create symbolic links. Junctions are used as an "
×
427
          + "alternative, however, these can not point to relative paths. So the source (" + source + ") is interpreted as an absolute path.");
428
      try {
429
        fallbackPath = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
×
430
      } catch (IOException e) {
×
431
        throw new IllegalStateException(
×
432
            "Since Windows junctions are used, the source must be an absolute path. The transformation of the passed " + "source (" + source
433
                + ") to an absolute path failed.", e);
434
      }
×
435

436
    } else {
437
      fallbackPath = source;
×
438
    }
439
    if (!Files.isDirectory(fallbackPath)) { // if source is a junction. This returns true as well.
×
440
      throw new IllegalStateException(
×
441
          "These junctions can only point to directories or other junctions. Please make sure that the source (" + fallbackPath + ") is one of these.");
442
    }
443
    this.context.newProcess().executable("cmd").addArgs("/c", "mklink", "/d", "/j", targetLink.toString(), fallbackPath.toString()).run();
×
444
  }
×
445

446
  @Override
447
  public void symlink(Path source, Path targetLink, boolean relative) {
448

449
    Path adaptedSource = null;
2✔
450
    try {
451
      adaptedSource = adaptPath(source, targetLink, relative);
6✔
452
    } catch (IOException e) {
×
453
      throw new IllegalStateException("Failed to adapt source for source (" + source + ") target (" + targetLink + ") and relative (" + relative + ")", e);
×
454
    }
1✔
455
    this.context.trace("Creating {} symbolic link {} pointing to {}", adaptedSource.isAbsolute() ? "" : "relative", targetLink, adaptedSource);
23✔
456

457
    try {
458
      deleteLinkIfExists(targetLink);
3✔
459
    } catch (IOException e) {
×
460
      throw new IllegalStateException("Failed to delete previous symlink or Windows junction at " + targetLink, e);
×
461
    }
1✔
462

463
    try {
464
      Files.createSymbolicLink(targetLink, adaptedSource);
6✔
465
    } catch (FileSystemException e) {
×
466
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
467
        this.context.info("Due to lack of permissions, Microsoft's mklink with junction had to be used to create "
×
468
            + "a Symlink. See https://github.com/devonfw/IDEasy/blob/main/documentation/symlinks.asciidoc for " + "further details. Error was: "
469
            + e.getMessage());
×
470
        createWindowsJunction(adaptedSource, targetLink);
×
471
      } else {
472
        throw new RuntimeException(e);
×
473
      }
474
    } catch (IOException e) {
×
475
      throw new IllegalStateException(
×
476
          "Failed to create a " + (adaptedSource.isAbsolute() ? "" : "relative") + "symbolic link " + targetLink + " pointing to " + source, e);
×
477
    }
1✔
478
  }
1✔
479

480
  @Override
481
  public Path toRealPath(Path path) {
482

483
    try {
484
      Path realPath = path.toRealPath();
×
485
      if (!realPath.equals(path)) {
×
486
        this.context.trace("Resolved path {} to {}", path, realPath);
×
487
      }
488
      return realPath;
×
489
    } catch (IOException e) {
×
490
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
491
    }
492
  }
493

494
  @Override
495
  public Path createTempDir(String name) {
496

497
    try {
498
      Path tmp = this.context.getTempPath();
4✔
499
      Path tempDir = tmp.resolve(name);
4✔
500
      int tries = 1;
2✔
501
      while (Files.exists(tempDir)) {
5!
502
        long id = System.nanoTime() & 0xFFFF;
×
503
        tempDir = tmp.resolve(name + "-" + id);
×
504
        tries++;
×
505
        if (tries > 200) {
×
506
          throw new IOException("Unable to create unique name!");
×
507
        }
508
      }
×
509
      return Files.createDirectory(tempDir);
5✔
510
    } catch (IOException e) {
×
511
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
512
    }
513
  }
514

515
  @Override
516
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
517

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

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

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

576
  /**
577
   * @param path the {@link Path} to start the recursive search from.
578
   * @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
579
   * item in their respective directory and {@code s} is not named "bin".
580
   */
581
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
582

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

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

603
    extractArchive(file, targetDir, in -> new ZipArchiveInputStream(in));
×
604
  }
×
605

606
  @Override
607
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
608

609
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
610
  }
1✔
611

612
  @Override
613
  public void extractJar(Path file, Path targetDir) {
614

615
    this.context.trace("Unpacking JAR {} to {}", file, targetDir);
14✔
616
    try (JarInputStream jis = new JarInputStream(Files.newInputStream(file))) {
8✔
617
      JarEntry entry;
618
      while ((entry = jis.getNextJarEntry()) != null) {
5✔
619
        Path entryPath = targetDir.resolve(entry.getName()).toAbsolutePath();
6✔
620

621
        if (!entryPath.startsWith(targetDir)) {
4!
622
          throw new IOException("Preventing path traversal attack from " + entry.getName() + " to " + entryPath);
×
623
        }
624

625
        if (entry.isDirectory()) {
3✔
626
          Files.createDirectories(entryPath);
6✔
627
        } else {
628
          Files.createDirectories(entryPath.getParent());
6✔
629
          Files.copy(jis, entryPath);
6✔
630
        }
631

632
        jis.closeEntry();
2✔
633
      }
1✔
634
    } catch (IOException e) {
×
635
      throw new IllegalStateException("Failed to extract JAR " + file + " to " + targetDir, e);
×
636
    }
1✔
637
  }
1✔
638

639
  /**
640
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
641
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
642
   */
643
  public static String generatePermissionString(int permissions) {
644

645
    // Ensure that only the last 9 bits are considered
646
    permissions &= 0b111111111;
4✔
647

648
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
649

650
    for (int i = 0; i < 9; i++) {
7✔
651
      int mask = 1 << i;
4✔
652
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
653
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
654
    }
655

656
    return permissionStringBuilder.toString();
3✔
657
  }
658

659
  private void extractArchive(Path file, Path targetDir, Function<InputStream, ArchiveInputStream> unpacker) {
660

661
    this.context.trace("Unpacking archive {} to {}", file, targetDir);
14✔
662
    try (InputStream is = Files.newInputStream(file); ArchiveInputStream ais = unpacker.apply(is)) {
10✔
663
      ArchiveEntry entry = ais.getNextEntry();
3✔
664
      boolean isTar = ais instanceof TarArchiveInputStream;
3✔
665
      while (entry != null) {
2✔
666
        String permissionStr = null;
2✔
667
        if (isTar) {
2!
668
          int tarMode = ((TarArchiveEntry) entry).getMode();
4✔
669
          permissionStr = generatePermissionString(tarMode);
3✔
670
        }
671

672
        Path entryName = Path.of(entry.getName());
6✔
673
        Path entryPath = targetDir.resolve(entryName).toAbsolutePath();
5✔
674
        if (!entryPath.startsWith(targetDir)) {
4!
675
          throw new IOException("Preventing path traversal attack from " + entryName + " to " + entryPath);
×
676
        }
677
        if (entry.isDirectory()) {
3✔
678
          mkdirs(entryPath);
4✔
679
        } else {
680
          // ensure the file can also be created if directory entry was missing or out of order...
681
          mkdirs(entryPath.getParent());
4✔
682
          Files.copy(ais, entryPath);
6✔
683
        }
684
        if (isTar && !this.context.getSystemInfo().isWindows()) {
7!
685
          Set<PosixFilePermission> permissions = PosixFilePermissions.fromString(permissionStr);
3✔
686
          Files.setPosixFilePermissions(entryPath, permissions);
4✔
687
        }
688
        entry = ais.getNextEntry();
3✔
689
      }
1✔
690
    } catch (IOException e) {
×
691
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
692
    }
1✔
693
  }
1✔
694

695
  @Override
696
  public void extractDmg(Path file, Path targetDir) {
697

698
    assert this.context.getSystemInfo().isMac();
×
699

700
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
701
    mkdirs(mountPath);
×
702
    ProcessContext pc = this.context.newProcess();
×
703
    pc.executable("hdiutil");
×
704
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
705
    pc.run();
×
706
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
707
    if (appPath == null) {
×
708
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
709
    }
710

711
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
712
    pc.addArgs("detach", "-force", mountPath);
×
713
    pc.run();
×
714
  }
×
715

716
  @Override
717
  public void extractMsi(Path file, Path targetDir) {
718

719
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
720
    // msiexec also creates a copy of the MSI
721
    Path msiCopy = targetDir.resolve(file.getFileName());
×
722
    delete(msiCopy);
×
723
  }
×
724

725
  @Override
726
  public void extractPkg(Path file, Path targetDir) {
727

728
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
729
    ProcessContext pc = this.context.newProcess();
×
730
    // we might also be able to use cpio from commons-compression instead of external xar...
731
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
732
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
733
    extractTar(contentPath, targetDir, TarCompression.GZ);
×
734
    delete(tmpDirPkg);
×
735
  }
×
736

737
  @Override
738
  public void delete(Path path) {
739

740
    if (!Files.exists(path)) {
5✔
741
      this.context.trace("Deleting {} skipped as the path does not exist.", path);
10✔
742
      return;
1✔
743
    }
744
    this.context.debug("Deleting {} ...", path);
10✔
745
    try {
746
      if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
747
        Files.delete(path);
×
748
      } else {
749
        deleteRecursive(path);
3✔
750
      }
751
    } catch (IOException e) {
×
752
      throw new IllegalStateException("Failed to delete " + path, e);
×
753
    }
1✔
754
  }
1✔
755

756
  private void deleteRecursive(Path path) throws IOException {
757

758
    if (Files.isDirectory(path)) {
5✔
759
      try (Stream<Path> childStream = Files.list(path)) {
3✔
760
        Iterator<Path> iterator = childStream.iterator();
3✔
761
        while (iterator.hasNext()) {
3✔
762
          Path child = iterator.next();
4✔
763
          deleteRecursive(child);
3✔
764
        }
1✔
765
      }
766
    }
767
    this.context.trace("Deleting {} ...", path);
10✔
768
    Files.delete(path);
2✔
769
  }
1✔
770

771
  @Override
772
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
773

774
    try {
775
      if (!Files.isDirectory(dir)) {
5✔
776
        return null;
2✔
777
      }
778
      return findFirstRecursive(dir, filter, recursive);
6✔
779
    } catch (IOException e) {
×
780
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
781
    }
782
  }
783

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

786
    List<Path> folders = null;
2✔
787
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
788
      Iterator<Path> iterator = childStream.iterator();
3✔
789
      while (iterator.hasNext()) {
3✔
790
        Path child = iterator.next();
4✔
791
        if (filter.test(child)) {
4✔
792
          return child;
4✔
793
        } else if (recursive && Files.isDirectory(child)) {
2!
794
          if (folders == null) {
×
795
            folders = new ArrayList<>();
×
796
          }
797
          folders.add(child);
×
798
        }
799
      }
1✔
800
    }
4!
801
    if (folders != null) {
2!
802
      for (Path child : folders) {
×
803
        Path match = findFirstRecursive(child, filter, recursive);
×
804
        if (match != null) {
×
805
          return match;
×
806
        }
807
      }
×
808
    }
809
    return null;
2✔
810
  }
811

812
  @Override
813
  public List<Path> listChildren(Path dir, Predicate<Path> filter) {
814

815
    if (!Files.isDirectory(dir)) {
5✔
816
      return List.of();
2✔
817
    }
818
    List<Path> children = new ArrayList<>();
4✔
819
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
820
      Iterator<Path> iterator = childStream.iterator();
3✔
821
      while (iterator.hasNext()) {
3✔
822
        Path child = iterator.next();
4✔
823
        if (filter.test(child)) {
4!
824
          this.context.trace("Accepted file {}", child);
10✔
825
          children.add(child);
5✔
826
        } else {
827
          this.context.trace("Ignoring file {} according to filter", child);
×
828
        }
829
      }
1✔
830
    } catch (IOException e) {
×
831
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
832
    }
1✔
833
    return children;
2✔
834
  }
835

836
  @Override
837
  public boolean isEmptyDir(Path dir) {
838

839
    return listChildren(dir, f -> true).isEmpty();
8✔
840
  }
841

842
  @Override
843
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
844

845
    for (Path dir : searchDirs) {
10!
846
      Path filePath = dir.resolve(fileName);
4✔
847
      try {
848
        if (Files.exists(filePath)) {
5!
849
          return filePath;
2✔
850
        }
851
      } catch (Exception e) {
×
852
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
853
      }
×
854
    }
×
855
    return null;
×
856
  }
857
}
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