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

devonfw / IDEasy / 12750149717

13 Jan 2025 03:10PM UTC coverage: 68.092% (+0.6%) from 67.541%
12750149717

Pull #820

github

web-flow
Merge 263280186 into 8e971e1a8
Pull Request #820: #759: upgrade settings commandlet

2690 of 4311 branches covered (62.4%)

Branch coverage included in aggregate %.

6947 of 9842 relevant lines covered (70.59%)

3.1 hits per line

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

64.77
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.HashSet;
31
import java.util.Iterator;
32
import java.util.List;
33
import java.util.Map;
34
import java.util.Set;
35
import java.util.function.Consumer;
36
import java.util.function.Function;
37
import java.util.function.Predicate;
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

45
import com.devonfw.tools.ide.cli.CliException;
46
import com.devonfw.tools.ide.cli.CliOfflineException;
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 {
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 static final Map<String, String> FS_ENV = Map.of("encoding", "UTF-8");
5✔
67

68
  private final IdeContext context;
69

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

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

81
  private HttpClient createHttpClient(String url) {
82

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

247
  private boolean isJunction(Path path) {
248

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

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

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

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

286
  private static Path appendParentPath(Path path, Path parent, int max) {
287

288
    if ((parent == null) || (max <= 0)) {
4!
289
      return path;
2✔
290
    }
291
    return appendParentPath(path, parent.getParent(), max - 1).resolve(parent.getFileName());
11✔
292
  }
293

294
  @Override
295
  public void move(Path source, Path targetDir) {
296

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

310
  @Override
311
  public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) {
312

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

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

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

379
  /**
380
   * 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
381
   * {@link Path} that is neither a symbolic link nor a Windows junction.
382
   *
383
   * @param path the {@link Path} to delete.
384
   * @throws IOException if the actual {@link Files#delete(Path) deletion} fails.
385
   */
386
  private void deleteLinkIfExists(Path path) throws IOException {
387

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

391
    assert !(isSymlink && isJunction);
5!
392

393
    if (isJunction || isSymlink) {
4!
394
      this.context.info("Deleting previous " + (isJunction ? "junction" : "symlink") + " at " + path);
8!
395
      Files.delete(path);
2✔
396
    }
397
  }
1✔
398

399
  /**
400
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
401
   * is applied to {@code source}.
402
   *
403
   * @param source the {@link Path} to adapt.
404
   * @param targetLink the {@link Path} used to calculate the relative path to the {@code source} if {@code relative} is set to {@code true}.
405
   * @param relative the {@code relative} flag.
406
   * @return the adapted {@link Path}.
407
   * @see FileAccessImpl#symlink(Path, Path, boolean)
408
   */
409
  private Path adaptPath(Path source, Path targetLink, boolean relative) throws IOException {
410

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

439
  /**
440
   * Creates a Windows junction at {@code targetLink} pointing to {@code source}.
441
   *
442
   * @param source must be another Windows junction or a directory.
443
   * @param targetLink the location of the Windows junction.
444
   */
445
  private void createWindowsJunction(Path source, Path targetLink) {
446

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

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

470
  @Override
471
  public void symlink(Path source, Path targetLink, boolean relative) {
472

473
    Path adaptedSource = null;
2✔
474
    try {
475
      adaptedSource = adaptPath(source, targetLink, relative);
6✔
476
    } catch (IOException e) {
×
477
      throw new IllegalStateException("Failed to adapt source for source (" + source + ") target (" + targetLink + ") and relative (" + relative + ")", e);
×
478
    }
1✔
479
    this.context.trace("Creating {} symbolic link {} pointing to {}", adaptedSource.isAbsolute() ? "" : "relative", targetLink, adaptedSource);
23✔
480

481
    try {
482
      deleteLinkIfExists(targetLink);
3✔
483
    } catch (IOException e) {
×
484
      throw new IllegalStateException("Failed to delete previous symlink or Windows junction at " + targetLink, e);
×
485
    }
1✔
486

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

504
  @Override
505
  public Path toRealPath(Path path) {
506

507
    try {
508
      Path realPath = path.toRealPath();
×
509
      if (!realPath.equals(path)) {
×
510
        this.context.trace("Resolved path {} to {}", path, realPath);
×
511
      }
512
      return realPath;
×
513
    } catch (IOException e) {
×
514
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
515
    }
516
  }
517

518
  @Override
519
  public Path createTempDir(String name) {
520

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

539
  @Override
540
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
541

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

582
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
583

584
    if (postExtractHook != null) {
2✔
585
      postExtractHook.accept(properInstallDir);
3✔
586
    }
587
  }
1✔
588

589
  /**
590
   * @param path the {@link Path} to start the recursive search from.
591
   * @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
592
   *     item in their respective directory and {@code s} is not named "bin".
593
   */
594
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
595

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

613
  @Override
614
  public void extractZip(Path file, Path targetDir) {
615

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

633
  @SuppressWarnings("unchecked")
634
  private void onFileCopiedFromZip(Path source, Path target, boolean directory, IdeProgressBar progressBar) {
635

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

652
  @Override
653
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
654

655
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
656
  }
1✔
657

658
  @Override
659
  public void extractJar(Path file, Path targetDir) {
660

661
    extractZip(file, targetDir);
4✔
662
  }
1✔
663

664
  /**
665
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
666
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
667
   */
668
  public static String generatePermissionString(int permissions) {
669

670
    // Ensure that only the last 9 bits are considered
671
    permissions &= 0b111111111;
4✔
672

673
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
674
    for (int i = 0; i < 9; i++) {
7✔
675
      int mask = 1 << i;
4✔
676
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
677
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
678
    }
679

680
    return permissionStringBuilder.toString();
3✔
681
  }
682

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

685
    this.context.info("Extracting TAR file {} to {}", file, targetDir);
14✔
686
    try (InputStream is = Files.newInputStream(file);
5✔
687
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
688
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
689

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

722
  @Override
723
  public void extractDmg(Path file, Path targetDir) {
724

725
    this.context.info("Extracting DMG file {} to {}", file, targetDir);
×
726
    assert this.context.getSystemInfo().isMac();
×
727

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

739
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
740
    pc.addArgs("detach", "-force", mountPath);
×
741
    pc.run();
×
742
  }
×
743

744
  @Override
745
  public void extractMsi(Path file, Path targetDir) {
746

747
    this.context.info("Extracting MSI file {} to {}", file, targetDir);
×
748
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
749
    // msiexec also creates a copy of the MSI
750
    Path msiCopy = targetDir.resolve(file.getFileName());
×
751
    delete(msiCopy);
×
752
  }
×
753

754
  @Override
755
  public void extractPkg(Path file, Path targetDir) {
756

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

767
  @Override
768
  public void delete(Path path) {
769

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

786
  private void deleteRecursive(Path path) throws IOException {
787

788
    if (Files.isDirectory(path)) {
5✔
789
      try (Stream<Path> childStream = Files.list(path)) {
3✔
790
        Iterator<Path> iterator = childStream.iterator();
3✔
791
        while (iterator.hasNext()) {
3✔
792
          Path child = iterator.next();
4✔
793
          deleteRecursive(child);
3✔
794
        }
1✔
795
      }
796
    }
797
    this.context.trace("Deleting {} ...", path);
10✔
798
    Files.delete(path);
2✔
799
  }
1✔
800

801
  @Override
802
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
803

804
    try {
805
      if (!Files.isDirectory(dir)) {
5✔
806
        return null;
2✔
807
      }
808
      return findFirstRecursive(dir, filter, recursive);
6✔
809
    } catch (IOException e) {
×
810
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
811
    }
812
  }
813

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

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

842
  @Override
843
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
844

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

871
  @Override
872
  public boolean isEmptyDir(Path dir) {
873

874
    return listChildren(dir, f -> true).isEmpty();
8✔
875
  }
876

877
  private long getFileSize(Path file) {
878

879
    try {
880
      return Files.size(file);
3✔
881
    } catch (IOException e) {
×
882
      this.context.warning(e.getMessage(), e);
×
883
      return 0;
×
884
    }
885
  }
886

887
  private long getFileSizeRecursive(Path path) {
888

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

906
  @Override
907
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
908

909
    for (Path dir : searchDirs) {
10!
910
      Path filePath = dir.resolve(fileName);
4✔
911
      try {
912
        if (Files.exists(filePath)) {
5!
913
          return filePath;
2✔
914
        }
915
      } catch (Exception e) {
×
916
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
917
      }
×
918
    }
×
919
    return null;
×
920
  }
921

922
  @Override
923
  public void makeExecutable(Path file, boolean confirm) {
924

925
    if (Files.exists(file)) {
5✔
926
      if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
927
        this.context.trace("Windows does not have executable flags hence omitting for file {}", file);
×
928
        return;
×
929
      }
930
      try {
931
        // Read the current file permissions
932
        Set<PosixFilePermission> existingPermissions = Files.getPosixFilePermissions(file);
5✔
933

934
        // Add execute permission for all users
935
        Set<PosixFilePermission> executablePermissions = new HashSet<>(existingPermissions);
5✔
936
        boolean update = false;
2✔
937
        update |= executablePermissions.add(PosixFilePermission.OWNER_EXECUTE);
6✔
938
        update |= executablePermissions.add(PosixFilePermission.GROUP_EXECUTE);
6✔
939
        update |= executablePermissions.add(PosixFilePermission.OTHERS_EXECUTE);
6✔
940

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

970
  @Override
971
  public void touch(Path file) {
972

973
    if (Files.exists(file)) {
5✔
974
      try {
975
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
976
      } catch (IOException e) {
×
977
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
978
      }
1✔
979
    } else {
980
      try {
981
        Files.createFile(file);
5✔
982
      } catch (IOException e) {
1✔
983
        throw new IllegalStateException("Could not create empty file " + file, e);
7✔
984
      }
1✔
985
    }
986
  }
1✔
987
}
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