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

devonfw / IDEasy / 13210157275

08 Feb 2025 12:10AM UTC coverage: 68.25% (-0.1%) from 68.379%
13210157275

Pull #1021

github

web-flow
Merge 93d542ee7 into 9c2006bd8
Pull Request #1021: #786: support ide upgrade to automatically update to the latest version of IDEasy

2910 of 4683 branches covered (62.14%)

Branch coverage included in aggregate %.

7563 of 10662 relevant lines covered (70.93%)

3.09 hits per line

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

65.04
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.io.Reader;
10
import java.io.Writer;
11
import java.net.URI;
12
import java.net.http.HttpClient;
13
import java.net.http.HttpClient.Redirect;
14
import java.net.http.HttpRequest;
15
import java.net.http.HttpResponse;
16
import java.nio.file.FileSystem;
17
import java.nio.file.FileSystemException;
18
import java.nio.file.FileSystems;
19
import java.nio.file.Files;
20
import java.nio.file.LinkOption;
21
import java.nio.file.NoSuchFileException;
22
import java.nio.file.Path;
23
import java.nio.file.attribute.BasicFileAttributes;
24
import java.nio.file.attribute.FileTime;
25
import java.nio.file.attribute.PosixFilePermission;
26
import java.nio.file.attribute.PosixFilePermissions;
27
import java.security.DigestInputStream;
28
import java.security.MessageDigest;
29
import java.security.NoSuchAlgorithmException;
30
import java.time.LocalDateTime;
31
import java.util.ArrayList;
32
import java.util.HashSet;
33
import java.util.Iterator;
34
import java.util.List;
35
import java.util.Map;
36
import java.util.Properties;
37
import java.util.Set;
38
import java.util.function.Consumer;
39
import java.util.function.Function;
40
import java.util.function.Predicate;
41
import java.util.stream.Stream;
42

43
import org.apache.commons.compress.archivers.ArchiveEntry;
44
import org.apache.commons.compress.archivers.ArchiveInputStream;
45
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
46
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
47

48
import com.devonfw.tools.ide.cli.CliException;
49
import com.devonfw.tools.ide.cli.CliOfflineException;
50
import com.devonfw.tools.ide.context.IdeContext;
51
import com.devonfw.tools.ide.os.SystemInfoImpl;
52
import com.devonfw.tools.ide.process.ProcessContext;
53
import com.devonfw.tools.ide.util.DateTimeUtil;
54
import com.devonfw.tools.ide.util.FilenameUtil;
55
import com.devonfw.tools.ide.util.HexUtil;
56

57
/**
58
 * Implementation of {@link FileAccess}.
59
 */
60
public class FileAccessImpl implements FileAccess {
61

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

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

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

70
  private final IdeContext context;
71

72
  /**
73
   * The constructor.
74
   *
75
   * @param context the {@link IdeContext} to use.
76
   */
77
  public FileAccessImpl(IdeContext context) {
78

79
    super();
2✔
80
    this.context = context;
3✔
81
  }
1✔
82

83
  private HttpClient createHttpClient(String url) {
84

85
    HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS);
4✔
86
    return builder.build();
3✔
87
  }
88

89
  @Override
90
  public void download(String url, Path target) {
91

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

100
        HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
7✔
101
        HttpClient client = createHttpClient(url);
4✔
102
        HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
5✔
103
        int statusCode = response.statusCode();
3✔
104
        if (statusCode == 200) {
3!
105
          downloadFileWithProgressBar(url, target, response);
6✔
106
        } else {
107
          throw new IllegalStateException("Download failed with status code " + statusCode);
×
108
        }
109
      } else if (url.startsWith("ftp") || url.startsWith("sftp")) {
9!
110
        throw new IllegalArgumentException("Unsupported download URL: " + url);
×
111
      } else {
112
        Path source = Path.of(url);
5✔
113
        if (isFile(source)) {
4!
114
          // network drive
115

116
          copyFileWithProgressBar(source, target);
5✔
117
        } else {
118
          throw new IllegalArgumentException("Download path does not point to a downloadable file: " + url);
×
119
        }
120
      }
121
    } catch (Exception e) {
×
122
      throw new IllegalStateException("Failed to download file from URL " + url + " to " + target, e);
×
123
    }
1✔
124
  }
1✔
125

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

135
    long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1);
7✔
136
    informAboutMissingContentLength(contentLength, url);
4✔
137

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

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

156
    } catch (Exception e) {
×
157
      throw new RuntimeException(e);
×
158
    }
1✔
159
  }
1✔
160

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

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

186
  private void informAboutMissingContentLength(long contentLength, String url) {
187

188
    if (contentLength < 0) {
4✔
189
      this.context.warning("Content-Length was not provided by download from {}", url);
10✔
190
    }
191
  }
1✔
192

193
  @Override
194
  public void mkdirs(Path directory) {
195

196
    if (Files.isDirectory(directory)) {
5✔
197
      return;
1✔
198
    }
199
    this.context.trace("Creating directory {}", directory);
10✔
200
    try {
201
      Files.createDirectories(directory);
5✔
202
    } catch (IOException e) {
×
203
      throw new IllegalStateException("Failed to create directory " + directory, e);
×
204
    }
1✔
205
  }
1✔
206

207
  @Override
208
  public boolean isFile(Path file) {
209

210
    if (!Files.exists(file)) {
5!
211
      this.context.trace("File {} does not exist", file);
×
212
      return false;
×
213
    }
214
    if (Files.isDirectory(file)) {
5!
215
      this.context.trace("Path {} is a directory but a regular file was expected", file);
×
216
      return false;
×
217
    }
218
    return true;
2✔
219
  }
220

221
  @Override
222
  public boolean isExpectedFolder(Path folder) {
223

224
    if (Files.isDirectory(folder)) {
5!
225
      return true;
2✔
226
    }
227
    this.context.warning("Expected folder was not found at {}", folder);
×
228
    return false;
×
229
  }
230

231
  @Override
232
  public String checksum(Path file, String hashAlgorithm) {
233

234
    MessageDigest md;
235
    try {
236
      md = MessageDigest.getInstance(hashAlgorithm);
×
237
    } catch (NoSuchAlgorithmException e) {
×
238
      throw new IllegalStateException("No such hash algorithm " + hashAlgorithm, e);
×
239
    }
×
240
    byte[] buffer = new byte[1024];
×
241
    try (InputStream is = Files.newInputStream(file); DigestInputStream dis = new DigestInputStream(is, md)) {
×
242
      int read = 0;
×
243
      while (read >= 0) {
×
244
        read = dis.read(buffer);
×
245
      }
246
    } catch (Exception e) {
×
247
      throw new IllegalStateException("Failed to read and hash file " + file, e);
×
248
    }
×
249
    byte[] digestBytes = md.digest();
×
250
    return HexUtil.toHexString(digestBytes);
×
251
  }
252

253
  public boolean isJunction(Path path) {
254

255
    if (!SystemInfoImpl.INSTANCE.isWindows()) {
3!
256
      return false;
2✔
257
    }
258

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

270
  @Override
271
  public Path backup(Path fileOrFolder) {
272

273
    if ((fileOrFolder != null) && (Files.isSymbolicLink(fileOrFolder) || isJunction(fileOrFolder))) {
9!
274
      delete(fileOrFolder);
×
275
    } else if ((fileOrFolder != null) && Files.exists(fileOrFolder)) {
7!
276
      LocalDateTime now = LocalDateTime.now();
2✔
277
      String date = DateTimeUtil.formatDate(now, true);
4✔
278
      String time = DateTimeUtil.formatTime(now);
3✔
279
      String filename = fileOrFolder.getFileName().toString();
4✔
280
      Path backupPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_BACKUPS).resolve(date).resolve(time + "_" + filename);
12✔
281
      backupPath = appendParentPath(backupPath, fileOrFolder.getParent(), 2);
6✔
282
      mkdirs(backupPath);
3✔
283
      Path target = backupPath.resolve(filename);
4✔
284
      this.context.info("Creating backup by moving {} to {}", fileOrFolder, target);
14✔
285
      move(fileOrFolder, target);
4✔
286
      return target;
2✔
287
    } else {
288
      this.context.trace("Backup of {} skipped as the path does not exist.", fileOrFolder);
10✔
289
    }
290
    return fileOrFolder;
2✔
291
  }
292

293
  private static Path appendParentPath(Path path, Path parent, int max) {
294

295
    if ((parent == null) || (max <= 0)) {
4!
296
      return path;
2✔
297
    }
298
    return appendParentPath(path, parent.getParent(), max - 1).resolve(parent.getFileName());
11✔
299
  }
300

301
  @Override
302
  public void move(Path source, Path targetDir) {
303

304
    this.context.trace("Moving {} to {}", source, targetDir);
14✔
305
    try {
306
      Files.move(source, targetDir);
6✔
307
    } catch (IOException e) {
×
308
      String fileType = Files.isSymbolicLink(source) ? "symlink" : isJunction(source) ? "junction" : Files.isDirectory(source) ? "directory" : "file";
×
309
      String message = "Failed to move " + fileType + ": " + source + " to " + targetDir + ".";
×
310
      if (this.context.getSystemInfo().isWindows()) {
×
311
        message = message + "\n" + WINDOWS_FILE_LOCK_WARNING;
×
312
      }
313
      throw new IllegalStateException(message, e);
×
314
    }
1✔
315
  }
1✔
316

317
  @Override
318
  public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) {
319

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

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

364
    if (Files.isDirectory(source)) {
5✔
365
      mkdirs(target);
3✔
366
      try (Stream<Path> childStream = Files.list(source)) {
3✔
367
        Iterator<Path> iterator = childStream.iterator();
3✔
368
        while (iterator.hasNext()) {
3✔
369
          Path child = iterator.next();
4✔
370
          copyRecursive(child, target.resolve(child.getFileName().toString()), mode, listener);
10✔
371
        }
1✔
372
      }
373
      listener.onCopy(source, target, true);
6✔
374
    } else if (Files.exists(source)) {
5!
375
      if (mode.isOverrideFile()) {
3✔
376
        delete(target);
3✔
377
      }
378
      this.context.trace("Starting to {} {} to {}", mode.getOperation(), source, target);
19✔
379
      Files.copy(source, target);
6✔
380
      listener.onCopy(source, target, false);
6✔
381
    } else {
382
      throw new IOException("Path " + source + " does not exist.");
×
383
    }
384
  }
1✔
385

386
  /**
387
   * 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
388
   * {@link Path} that is neither a symbolic link nor a Windows junction.
389
   *
390
   * @param path the {@link Path} to delete.
391
   * @throws IOException if the actual {@link Files#delete(Path) deletion} fails.
392
   */
393
  private void deleteLinkIfExists(Path path) throws IOException {
394

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

398
    assert !(isSymlink && isJunction);
5!
399

400
    if (isJunction || isSymlink) {
4!
401
      this.context.info("Deleting previous " + (isJunction ? "junction" : "symlink") + " at " + path);
9!
402
      Files.delete(path);
2✔
403
    }
404
  }
1✔
405

406
  /**
407
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
408
   * is applied to {@code source}.
409
   *
410
   * @param source the {@link Path} to adapt.
411
   * @param targetLink the {@link Path} used to calculate the relative path to the {@code source} if {@code relative} is set to {@code true}.
412
   * @param relative the {@code relative} flag.
413
   * @return the adapted {@link Path}.
414
   * @see FileAccessImpl#symlink(Path, Path, boolean)
415
   */
416
  private Path adaptPath(Path source, Path targetLink, boolean relative) throws IOException {
417

418
    if (source.isAbsolute()) {
3✔
419
      try {
420
        source = source.toRealPath(LinkOption.NOFOLLOW_LINKS); // to transform ../d1/../d2 to ../d2
9✔
421
      } catch (IOException e) {
×
422
        throw new IOException("Calling toRealPath() on the source (" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
423
      }
1✔
424
      if (relative) {
2✔
425
        source = targetLink.getParent().relativize(source);
5✔
426
        // to make relative links like this work: dir/link -> dir
427
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
428
      }
429
    } else { // source is relative
430
      if (relative) {
2✔
431
        // even though the source is already relative, toRealPath should be called to transform paths like
432
        // this ../d1/../d2 to ../d2
433
        source = targetLink.getParent().relativize(targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS));
14✔
434
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
435
      } else { // !relative
436
        try {
437
          source = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
11✔
438
        } catch (IOException e) {
×
439
          throw new IOException("Calling toRealPath() on " + targetLink + ".resolveSibling(" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
440
        }
1✔
441
      }
442
    }
443
    return source;
2✔
444
  }
445

446
  /**
447
   * Creates a Windows junction at {@code targetLink} pointing to {@code source}.
448
   *
449
   * @param source must be another Windows junction or a directory.
450
   * @param targetLink the location of the Windows junction.
451
   */
452
  private void createWindowsJunction(Path source, Path targetLink) {
453

454
    this.context.trace("Creating a Windows junction at " + targetLink + " with " + source + " as source.");
×
455
    Path fallbackPath;
456
    if (!source.isAbsolute()) {
×
457
      this.context.warning("You are on Windows and you do not have permissions to create symbolic links. Junctions are used as an "
×
458
          + "alternative, however, these can not point to relative paths. So the source (" + source + ") is interpreted as an absolute path.");
459
      try {
460
        fallbackPath = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
×
461
      } catch (IOException e) {
×
462
        throw new IllegalStateException(
×
463
            "Since Windows junctions are used, the source must be an absolute path. The transformation of the passed " + "source (" + source
464
                + ") to an absolute path failed.", e);
465
      }
×
466

467
    } else {
468
      fallbackPath = source;
×
469
    }
470
    if (!Files.isDirectory(fallbackPath)) { // if source is a junction. This returns true as well.
×
471
      throw new IllegalStateException(
×
472
          "These junctions can only point to directories or other junctions. Please make sure that the source (" + fallbackPath + ") is one of these.");
473
    }
474
    this.context.newProcess().executable("cmd").addArgs("/c", "mklink", "/d", "/j", targetLink.toString(), fallbackPath.toString()).run();
×
475
  }
×
476

477
  @Override
478
  public void symlink(Path source, Path targetLink, boolean relative) {
479

480
    Path adaptedSource = null;
2✔
481
    try {
482
      adaptedSource = adaptPath(source, targetLink, relative);
6✔
483
    } catch (IOException e) {
×
484
      throw new IllegalStateException("Failed to adapt source for source (" + source + ") target (" + targetLink + ") and relative (" + relative + ")", e);
×
485
    }
1✔
486
    this.context.debug("Creating {} symbolic link {} pointing to {}", adaptedSource.isAbsolute() ? "" : "relative", targetLink, adaptedSource);
23✔
487

488
    try {
489
      deleteLinkIfExists(targetLink);
3✔
490
    } catch (IOException e) {
×
491
      throw new IllegalStateException("Failed to delete previous symlink or Windows junction at " + targetLink, e);
×
492
    }
1✔
493

494
    try {
495
      Files.createSymbolicLink(targetLink, adaptedSource);
6✔
496
    } catch (FileSystemException e) {
×
497
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
498
        this.context.info("Due to lack of permissions, Microsoft's mklink with junction had to be used to create "
×
499
            + "a Symlink. See https://github.com/devonfw/IDEasy/blob/main/documentation/symlinks.asciidoc for " + "further details. Error was: "
500
            + e.getMessage());
×
501
        createWindowsJunction(adaptedSource, targetLink);
×
502
      } else {
503
        throw new RuntimeException(e);
×
504
      }
505
    } catch (IOException e) {
×
506
      throw new IllegalStateException(
×
507
          "Failed to create a " + (adaptedSource.isAbsolute() ? "" : "relative") + "symbolic link " + targetLink + " pointing to " + source, e);
×
508
    }
1✔
509
  }
1✔
510

511
  @Override
512
  public Path toRealPath(Path path) {
513

514
    try {
515
      Path realPath = path.toRealPath();
5✔
516
      if (!realPath.equals(path)) {
4✔
517
        this.context.trace("Resolved path {} to {}", path, realPath);
14✔
518
      }
519
      return realPath;
2✔
520
    } catch (IOException e) {
×
521
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
522
    }
523
  }
524

525
  @Override
526
  public Path createTempDir(String name) {
527

528
    try {
529
      Path tmp = this.context.getTempPath();
4✔
530
      Path tempDir = tmp.resolve(name);
4✔
531
      int tries = 1;
2✔
532
      while (Files.exists(tempDir)) {
5!
533
        long id = System.nanoTime() & 0xFFFF;
×
534
        tempDir = tmp.resolve(name + "-" + id);
×
535
        tries++;
×
536
        if (tries > 200) {
×
537
          throw new IOException("Unable to create unique name!");
×
538
        }
539
      }
×
540
      return Files.createDirectory(tempDir);
5✔
541
    } catch (IOException e) {
×
542
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
543
    }
544
  }
545

546
  @Override
547
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
548

549
    if (Files.isDirectory(archiveFile)) {
5✔
550
      // TODO: check this case
551
      Path properInstallDir = archiveFile; // getProperInstallationSubDirOf(archiveFile, archiveFile);
2✔
552
      this.context.warning("Found directory for download at {} hence copying without extraction!", archiveFile);
10✔
553
      copy(properInstallDir, targetDir, FileCopyMode.COPY_TREE_CONTENT);
5✔
554
      postExtractHook(postExtractHook, targetDir);
4✔
555
      return;
1✔
556
    } else if (!extract) {
2!
557
      mkdirs(targetDir);
×
558
      move(archiveFile, targetDir.resolve(archiveFile.getFileName()));
×
559
      return;
×
560
    }
561
    Path tmpDir = createTempDir("extract-" + archiveFile.getFileName());
7✔
562
    this.context.trace("Trying to extract the downloaded file {} to {} and move it to {}.", archiveFile, tmpDir, targetDir);
18✔
563
    String filename = archiveFile.getFileName().toString();
4✔
564
    TarCompression tarCompression = TarCompression.of(filename);
3✔
565
    if (tarCompression != null) {
2✔
566
      extractTar(archiveFile, tmpDir, tarCompression);
6✔
567
    } else {
568
      String extension = FilenameUtil.getExtension(filename);
3✔
569
      if (extension == null) {
2!
570
        throw new IllegalStateException("Unknown archive format without extension - can not extract " + archiveFile);
×
571
      } else {
572
        this.context.trace("Determined file extension {}", extension);
10✔
573
      }
574
      switch (extension) {
8!
575
        case "zip" -> extractZip(archiveFile, tmpDir);
×
576
        case "jar" -> extractJar(archiveFile, tmpDir);
5✔
577
        case "dmg" -> extractDmg(archiveFile, tmpDir);
×
578
        case "msi" -> extractMsi(archiveFile, tmpDir);
×
579
        case "pkg" -> extractPkg(archiveFile, tmpDir);
×
580
        default -> throw new IllegalStateException("Unknown archive format " + extension + ". Can not extract " + archiveFile);
×
581
      }
582
    }
583
    Path properInstallDir = getProperInstallationSubDirOf(tmpDir, archiveFile);
5✔
584
    postExtractHook(postExtractHook, properInstallDir);
4✔
585
    move(properInstallDir, targetDir);
4✔
586
    delete(tmpDir);
3✔
587
  }
1✔
588

589
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
590

591
    if (postExtractHook != null) {
2✔
592
      postExtractHook.accept(properInstallDir);
3✔
593
    }
594
  }
1✔
595

596
  /**
597
   * @param path the {@link Path} to start the recursive search from.
598
   * @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
599
   *     item in their respective directory and {@code s} is not named "bin".
600
   */
601
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
602

603
    try (Stream<Path> stream = Files.list(path)) {
3✔
604
      Path[] subFiles = stream.toArray(Path[]::new);
8✔
605
      if (subFiles.length == 0) {
3!
606
        throw new CliException("The downloaded package " + archiveFile + " seems to be empty as you can check in the extracted folder " + path);
×
607
      } else if (subFiles.length == 1) {
4✔
608
        String filename = subFiles[0].getFileName().toString();
6✔
609
        if (!filename.equals(IdeContext.FOLDER_BIN) && !filename.equals(IdeContext.FOLDER_CONTENTS) && !filename.endsWith(".app") && Files.isDirectory(
19!
610
            subFiles[0])) {
611
          return getProperInstallationSubDirOf(subFiles[0], archiveFile);
9✔
612
        }
613
      }
614
      return path;
4✔
615
    } catch (IOException e) {
4!
616
      throw new IllegalStateException("Failed to get sub-files of " + path);
×
617
    }
618
  }
619

620
  @Override
621
  public void extractZip(Path file, Path targetDir) {
622

623
    this.context.info("Extracting ZIP file {} to {}", file, targetDir);
14✔
624
    URI uri = URI.create("jar:" + file.toUri());
6✔
625
    try (FileSystem fs = FileSystems.newFileSystem(uri, FS_ENV)) {
4✔
626
      long size = 0;
2✔
627
      for (Path root : fs.getRootDirectories()) {
11✔
628
        size += getFileSizeRecursive(root);
6✔
629
      }
1✔
630
      try (final IdeProgressBar progressBar = this.context.newProgressbarForExtracting(size)) {
5✔
631
        for (Path root : fs.getRootDirectories()) {
11✔
632
          copy(root, targetDir, FileCopyMode.EXTRACT, (s, t, d) -> onFileCopiedFromZip(s, t, d, progressBar));
15✔
633
        }
1✔
634
      }
635
    } catch (IOException e) {
×
636
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
637
    }
1✔
638
  }
1✔
639

640
  @SuppressWarnings("unchecked")
641
  private void onFileCopiedFromZip(Path source, Path target, boolean directory, IdeProgressBar progressBar) {
642

643
    if (directory) {
2✔
644
      return;
1✔
645
    }
646
    if (!context.getSystemInfo().isWindows()) {
5✔
647
      try {
648
        Object attribute = Files.getAttribute(source, "zip:permissions");
6✔
649
        if (attribute instanceof Set<?> permissionSet) {
6✔
650
          Files.setPosixFilePermissions(target, (Set<PosixFilePermission>) permissionSet);
4✔
651
        }
652
      } catch (Exception e) {
×
653
        context.error(e, "Failed to transfer zip permissions for {}", target);
×
654
      }
1✔
655
    }
656
    progressBar.stepBy(getFileSize(target));
5✔
657
  }
1✔
658

659
  @Override
660
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
661

662
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
663
  }
1✔
664

665
  @Override
666
  public void extractJar(Path file, Path targetDir) {
667

668
    extractZip(file, targetDir);
4✔
669
  }
1✔
670

671
  /**
672
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
673
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
674
   */
675
  public static String generatePermissionString(int permissions) {
676

677
    // Ensure that only the last 9 bits are considered
678
    permissions &= 0b111111111;
4✔
679

680
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
681
    for (int i = 0; i < 9; i++) {
7✔
682
      int mask = 1 << i;
4✔
683
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
684
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
685
    }
686

687
    return permissionStringBuilder.toString();
3✔
688
  }
689

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

692
    this.context.info("Extracting TAR file {} to {}", file, targetDir);
14✔
693
    try (InputStream is = Files.newInputStream(file);
5✔
694
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
695
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
696

697
      ArchiveEntry entry = ais.getNextEntry();
3✔
698
      boolean isTar = ais instanceof TarArchiveInputStream;
3✔
699
      while (entry != null) {
2✔
700
        String permissionStr = null;
2✔
701
        if (isTar) {
2!
702
          int tarMode = ((TarArchiveEntry) entry).getMode();
4✔
703
          permissionStr = generatePermissionString(tarMode);
3✔
704
        }
705
        Path entryName = Path.of(entry.getName());
6✔
706
        Path entryPath = targetDir.resolve(entryName).toAbsolutePath();
5✔
707
        if (!entryPath.startsWith(targetDir)) {
4!
708
          throw new IOException("Preventing path traversal attack from " + entryName + " to " + entryPath);
×
709
        }
710
        if (entry.isDirectory()) {
3✔
711
          mkdirs(entryPath);
4✔
712
        } else {
713
          // ensure the file can also be created if directory entry was missing or out of order...
714
          mkdirs(entryPath.getParent());
4✔
715
          Files.copy(ais, entryPath);
6✔
716
        }
717
        if (isTar && !this.context.getSystemInfo().isWindows()) {
7!
718
          Set<PosixFilePermission> permissions = PosixFilePermissions.fromString(permissionStr);
3✔
719
          Files.setPosixFilePermissions(entryPath, permissions);
4✔
720
        }
721
        pb.stepBy(entry.getSize());
4✔
722
        entry = ais.getNextEntry();
3✔
723
      }
1✔
724
    } catch (IOException e) {
×
725
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
726
    }
1✔
727
  }
1✔
728

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

732
    this.context.info("Extracting DMG file {} to {}", file, targetDir);
×
733
    assert this.context.getSystemInfo().isMac();
×
734

735
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
736
    mkdirs(mountPath);
×
737
    ProcessContext pc = this.context.newProcess();
×
738
    pc.executable("hdiutil");
×
739
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
740
    pc.run();
×
741
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
742
    if (appPath == null) {
×
743
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
744
    }
745

746
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
747
    pc.addArgs("detach", "-force", mountPath);
×
748
    pc.run();
×
749
  }
×
750

751
  @Override
752
  public void extractMsi(Path file, Path targetDir) {
753

754
    this.context.info("Extracting MSI file {} to {}", file, targetDir);
×
755
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
756
    // msiexec also creates a copy of the MSI
757
    Path msiCopy = targetDir.resolve(file.getFileName());
×
758
    delete(msiCopy);
×
759
  }
×
760

761
  @Override
762
  public void extractPkg(Path file, Path targetDir) {
763

764
    this.context.info("Extracting PKG file {} to {}", file, targetDir);
×
765
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
766
    ProcessContext pc = this.context.newProcess();
×
767
    // we might also be able to use cpio from commons-compression instead of external xar...
768
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
769
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
770
    extractTar(contentPath, targetDir, TarCompression.GZ);
×
771
    delete(tmpDirPkg);
×
772
  }
×
773

774
  @Override
775
  public void delete(Path path) {
776

777
    if (!Files.exists(path)) {
5✔
778
      this.context.trace("Deleting {} skipped as the path does not exist.", path);
10✔
779
      return;
1✔
780
    }
781
    this.context.debug("Deleting {} ...", path);
10✔
782
    try {
783
      if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
784
        Files.delete(path);
×
785
      } else {
786
        deleteRecursive(path);
3✔
787
      }
788
    } catch (IOException e) {
×
789
      throw new IllegalStateException("Failed to delete " + path, e);
×
790
    }
1✔
791
  }
1✔
792

793
  private void deleteRecursive(Path path) throws IOException {
794

795
    if (Files.isDirectory(path)) {
5✔
796
      try (Stream<Path> childStream = Files.list(path)) {
3✔
797
        Iterator<Path> iterator = childStream.iterator();
3✔
798
        while (iterator.hasNext()) {
3✔
799
          Path child = iterator.next();
4✔
800
          deleteRecursive(child);
3✔
801
        }
1✔
802
      }
803
    }
804
    this.context.trace("Deleting {} ...", path);
10✔
805
    Files.delete(path);
2✔
806
  }
1✔
807

808
  @Override
809
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
810

811
    try {
812
      if (!Files.isDirectory(dir)) {
5✔
813
        return null;
2✔
814
      }
815
      return findFirstRecursive(dir, filter, recursive);
6✔
816
    } catch (IOException e) {
×
817
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
818
    }
819
  }
820

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

823
    List<Path> folders = null;
2✔
824
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
825
      Iterator<Path> iterator = childStream.iterator();
3✔
826
      while (iterator.hasNext()) {
3✔
827
        Path child = iterator.next();
4✔
828
        if (filter.test(child)) {
4✔
829
          return child;
4✔
830
        } else if (recursive && Files.isDirectory(child)) {
2!
831
          if (folders == null) {
×
832
            folders = new ArrayList<>();
×
833
          }
834
          folders.add(child);
×
835
        }
836
      }
1✔
837
    }
4!
838
    if (folders != null) {
2!
839
      for (Path child : folders) {
×
840
        Path match = findFirstRecursive(child, filter, recursive);
×
841
        if (match != null) {
×
842
          return match;
×
843
        }
844
      }
×
845
    }
846
    return null;
2✔
847
  }
848

849
  @Override
850
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
851

852
    if (!Files.isDirectory(dir)) {
5✔
853
      return List.of();
2✔
854
    }
855
    List<Path> children = new ArrayList<>();
4✔
856
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
857
      Iterator<Path> iterator = childStream.iterator();
3✔
858
      while (iterator.hasNext()) {
3✔
859
        Path child = iterator.next();
4✔
860
        Path filteredChild = filter.apply(child);
5✔
861
        if (filteredChild != null) {
2✔
862
          if (filteredChild == child) {
3!
863
            this.context.trace("Accepted file {}", child);
11✔
864
          } else {
865
            this.context.trace("Accepted file {} and mapped to {}", child, filteredChild);
×
866
          }
867
          children.add(filteredChild);
5✔
868
        } else {
869
          this.context.trace("Ignoring file {} according to filter", child);
10✔
870
        }
871
      }
1✔
872
    } catch (IOException e) {
×
873
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
874
    }
1✔
875
    return children;
2✔
876
  }
877

878
  @Override
879
  public boolean isEmptyDir(Path dir) {
880

881
    return listChildren(dir, f -> true).isEmpty();
8✔
882
  }
883

884
  private long getFileSize(Path file) {
885

886
    try {
887
      return Files.size(file);
3✔
888
    } catch (IOException e) {
×
889
      this.context.warning(e.getMessage(), e);
×
890
      return 0;
×
891
    }
892
  }
893

894
  private long getFileSizeRecursive(Path path) {
895

896
    long size = 0;
2✔
897
    if (Files.isDirectory(path)) {
5✔
898
      try (Stream<Path> childStream = Files.list(path)) {
3✔
899
        Iterator<Path> iterator = childStream.iterator();
3✔
900
        while (iterator.hasNext()) {
3✔
901
          Path child = iterator.next();
4✔
902
          size += getFileSizeRecursive(child);
6✔
903
        }
1✔
904
      } catch (IOException e) {
×
905
        throw new RuntimeException("Failed to iterate children of folder " + path, e);
×
906
      }
1✔
907
    } else {
908
      size += getFileSize(path);
6✔
909
    }
910
    return size;
2✔
911
  }
912

913
  @Override
914
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
915

916
    for (Path dir : searchDirs) {
×
917
      Path filePath = dir.resolve(fileName);
×
918
      try {
919
        if (Files.exists(filePath)) {
×
920
          return filePath;
×
921
        }
922
      } catch (Exception e) {
×
923
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
924
      }
×
925
    }
×
926
    return null;
×
927
  }
928

929
  @Override
930
  public void makeExecutable(Path file, boolean confirm) {
931

932
    if (Files.exists(file)) {
5✔
933
      if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
934
        this.context.trace("Windows does not have executable flags hence omitting for file {}", file);
×
935
        return;
×
936
      }
937
      try {
938
        // Read the current file permissions
939
        Set<PosixFilePermission> existingPermissions = Files.getPosixFilePermissions(file);
5✔
940

941
        // Add execute permission for all users
942
        Set<PosixFilePermission> executablePermissions = new HashSet<>(existingPermissions);
5✔
943
        boolean update = false;
2✔
944
        update |= executablePermissions.add(PosixFilePermission.OWNER_EXECUTE);
6✔
945
        update |= executablePermissions.add(PosixFilePermission.GROUP_EXECUTE);
6✔
946
        update |= executablePermissions.add(PosixFilePermission.OTHERS_EXECUTE);
6✔
947

948
        if (update) {
2✔
949
          if (confirm) {
2!
950
            boolean yesContinue = this.context.question(
×
951
                "We want to execute " + file.getFileName() + " but this command seems to lack executable permissions!\n"
×
952
                    + "Most probably the tool vendor did forgot to add x-flags in the binary release package.\n"
953
                    + "Before running the command, we suggest to set executable permissions to the file:\n"
954
                    + file + "\n"
955
                    + "For security reasons we ask for your confirmation so please check this request.\n"
956
                    + "Changing permissions from " + PosixFilePermissions.toString(existingPermissions) + " to " + PosixFilePermissions.toString(
×
957
                    executablePermissions) + ".\n"
958
                    + "Do you confirm to make the command executable before running it?");
959
            if (!yesContinue) {
×
960
              return;
×
961
            }
962
          }
963
          this.context.debug("Setting executable flags for file {}", file);
10✔
964
          // Set the new permissions
965
          Files.setPosixFilePermissions(file, executablePermissions);
5✔
966
        } else {
967
          this.context.trace("Executable flags already present so no need to set them for file {}", file);
10✔
968
        }
969
      } catch (IOException e) {
×
970
        throw new RuntimeException(e);
×
971
      }
1✔
972
    } else {
973
      this.context.warning("Cannot set executable flag on file that does not exist: {}", file);
10✔
974
    }
975
  }
1✔
976

977
  @Override
978
  public void touch(Path file) {
979

980
    if (Files.exists(file)) {
5✔
981
      try {
982
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
983
      } catch (IOException e) {
×
984
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
985
      }
1✔
986
    } else {
987
      try {
988
        Files.createFile(file);
5✔
989
      } catch (IOException e) {
1✔
990
        throw new IllegalStateException("Could not create empty file " + file, e);
8✔
991
      }
1✔
992
    }
993
  }
1✔
994

995
  @Override
996
  public String readFileContent(Path file) {
997

998
    this.context.trace("Reading content of file from {}", file);
10✔
999
    try {
1000
      String content = Files.readString(file);
3✔
1001
      this.context.trace("Read content of length {} from file {}", content.length(), file);
16✔
1002
      return content;
2✔
1003
    } catch (IOException e) {
×
1004
      throw new IllegalStateException("Failed to read file " + file, e);
×
1005
    }
1006
  }
1007

1008
  @Override
1009
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1010

1011
    if (createParentDir) {
2!
1012
      mkdirs(file.getParent());
×
1013
    }
1014
    if (content == null) {
2!
1015
      content = "";
×
1016
    }
1017
    this.context.trace("Writing content with length {} to file {}", content.length(), file);
16✔
1018
    if (Files.exists(file)) {
5!
1019
      this.context.info("Overriding content of file {}", file);
×
1020
    }
1021
    try {
1022
      Files.writeString(file, content);
6✔
1023
      this.context.trace("Wrote content to file {}", file);
10✔
1024
    } catch (IOException e) {
×
1025
      throw new RuntimeException("Failed to write file " + file, e);
×
1026
    }
1✔
1027
  }
1✔
1028

1029
  @Override
1030
  public void readProperties(Path file, Properties properties) {
1031

1032
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1033
      properties.load(reader);
3✔
1034
      this.context.debug("Successfully loaded {} properties from {}", properties.size(), file);
16✔
1035
    } catch (IOException e) {
×
1036
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1037
    }
1✔
1038
  }
1✔
1039

1040
  @Override
1041
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1042

1043
    if (createParentDir) {
2✔
1044
      mkdirs(file.getParent());
4✔
1045
    }
1046
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1047
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1048
      this.context.debug("Successfully saved {} properties to {}", properties.size(), file);
16✔
1049
    } catch (IOException e) {
×
1050
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1051
    }
1✔
1052
  }
1✔
1053
}
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