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

devonfw / IDEasy / 15822788314

23 Jun 2025 11:18AM UTC coverage: 67.725% (-0.07%) from 67.793%
15822788314

Pull #1367

github

web-flow
Merge 5e7fd7169 into f0c645132
Pull Request #1367: #1304: Fix VSCode reinstall error

3180 of 5100 branches covered (62.35%)

Branch coverage included in aggregate %.

8145 of 11622 relevant lines covered (70.08%)

3.07 hits per line

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

67.93
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.StandardCopyOption;
24
import java.nio.file.attribute.BasicFileAttributes;
25
import java.nio.file.attribute.DosFileAttributeView;
26
import java.nio.file.attribute.FileTime;
27
import java.nio.file.attribute.PosixFileAttributeView;
28
import java.nio.file.attribute.PosixFilePermission;
29
import java.nio.file.attribute.PosixFilePermissions;
30
import java.security.DigestInputStream;
31
import java.security.MessageDigest;
32
import java.security.NoSuchAlgorithmException;
33
import java.time.Duration;
34
import java.time.LocalDateTime;
35
import java.util.ArrayList;
36
import java.util.HashSet;
37
import java.util.Iterator;
38
import java.util.List;
39
import java.util.Map;
40
import java.util.Properties;
41
import java.util.Set;
42
import java.util.function.Consumer;
43
import java.util.function.Function;
44
import java.util.function.Predicate;
45
import java.util.stream.Stream;
46

47
import org.apache.commons.compress.archivers.ArchiveEntry;
48
import org.apache.commons.compress.archivers.ArchiveInputStream;
49
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
50
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
51

52
import com.devonfw.tools.ide.cli.CliException;
53
import com.devonfw.tools.ide.cli.CliOfflineException;
54
import com.devonfw.tools.ide.context.IdeContext;
55
import com.devonfw.tools.ide.os.SystemInfoImpl;
56
import com.devonfw.tools.ide.process.ProcessContext;
57
import com.devonfw.tools.ide.util.DateTimeUtil;
58
import com.devonfw.tools.ide.util.FilenameUtil;
59
import com.devonfw.tools.ide.util.HexUtil;
60

61
/**
62
 * Implementation of {@link FileAccess}.
63
 */
64
public class FileAccessImpl implements FileAccess {
65

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

68
  private static final String WINDOWS_FILE_LOCK_WARNING =
69
      "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"
70
          + WINDOWS_FILE_LOCK_DOCUMENTATION_PAGE;
71

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

74
  private final IdeContext context;
75

76
  /**
77
   * The constructor.
78
   *
79
   * @param context the {@link IdeContext} to use.
80
   */
81
  public FileAccessImpl(IdeContext context) {
82

83
    super();
2✔
84
    this.context = context;
3✔
85
  }
1✔
86

87
  private HttpClient createHttpClient(String url) {
88

89
    HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS);
4✔
90
    return builder.build();
3✔
91
  }
92

93
  @Override
94
  public void download(String url, Path target) {
95

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

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

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

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

139
    long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1);
7✔
140
    informAboutMissingContentLength(contentLength, url);
4✔
141

142
    byte[] data = new byte[1024];
3✔
143
    boolean fileComplete = false;
2✔
144
    int count;
145

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

160
    } catch (Exception e) {
×
161
      throw new RuntimeException(e);
×
162
    }
1✔
163
  }
1✔
164

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

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

190
  private void informAboutMissingContentLength(long contentLength, String url) {
191

192
    if (contentLength < 0) {
4✔
193
      this.context.warning("Content-Length was not provided by download from {}", url);
10✔
194
    }
195
  }
1✔
196

197
  @Override
198
  public void mkdirs(Path directory) {
199

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

211
  @Override
212
  public boolean isFile(Path file) {
213

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

225
  @Override
226
  public boolean isExpectedFolder(Path folder) {
227

228
    if (Files.isDirectory(folder)) {
5✔
229
      return true;
2✔
230
    }
231
    this.context.warning("Expected folder was not found at {}", folder);
10✔
232
    return false;
2✔
233
  }
234

235
  @Override
236
  public String checksum(Path file, String hashAlgorithm) {
237

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

257
  public boolean isJunction(Path path) {
258

259
    if (!SystemInfoImpl.INSTANCE.isWindows()) {
3!
260
      return false;
2✔
261
    }
262

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

274
  @Override
275
  public Path backup(Path fileOrFolder) {
276

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

297
  private static Path appendParentPath(Path path, Path parent, int max) {
298

299
    if ((parent == null) || (max <= 0)) {
4!
300
      return path;
2✔
301
    }
302
    return appendParentPath(path, parent.getParent(), max - 1).resolve(parent.getFileName());
11✔
303
  }
304

305
  @Override
306
  public void move(Path source, Path targetDir, StandardCopyOption... copyOptions) {
307

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

321
  @Override
322
  public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) {
323

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

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

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

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

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

402
    assert !(isSymlink && isJunction);
5!
403

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

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

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

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

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

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

481
  @Override
482
  public void symlink(Path source, Path targetLink, boolean relative) {
483

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

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

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

515
  @Override
516
  public Path toRealPath(Path path) {
517

518
    return toRealPath(path, true);
5✔
519
  }
520

521
  @Override
522
  public Path toCanonicalPath(Path path) {
523

524
    return toRealPath(path, false);
5✔
525
  }
526

527
  private Path toRealPath(Path path, boolean resolveLinks) {
528

529
    try {
530
      Path realPath;
531
      if (resolveLinks) {
2✔
532
        realPath = path.toRealPath();
6✔
533
      } else {
534
        realPath = path.toRealPath(LinkOption.NOFOLLOW_LINKS);
9✔
535
      }
536
      if (!realPath.equals(path)) {
4✔
537
        this.context.trace("Resolved path {} to {}", path, realPath);
14✔
538
      }
539
      return realPath;
2✔
540
    } catch (IOException e) {
×
541
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
542
    }
543
  }
544

545
  @Override
546
  public Path createTempDir(String name) {
547

548
    try {
549
      Path tmp = this.context.getTempPath();
4✔
550
      Path tempDir = tmp.resolve(name);
4✔
551
      int tries = 1;
2✔
552
      while (Files.exists(tempDir)) {
5!
553
        long id = System.nanoTime() & 0xFFFF;
×
554
        tempDir = tmp.resolve(name + "-" + id);
×
555
        tries++;
×
556
        if (tries > 200) {
×
557
          throw new IOException("Unable to create unique name!");
×
558
        }
559
      }
×
560
      return Files.createDirectory(tempDir);
5✔
561
    } catch (IOException e) {
×
562
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
563
    }
564
  }
565

566
  @Override
567
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
568

569
    if (Files.isDirectory(archiveFile)) {
5✔
570
      // TODO: check this case
571
      Path properInstallDir = archiveFile; // getProperInstallationSubDirOf(archiveFile, archiveFile);
2✔
572
      this.context.warning("Found directory for download at {} hence copying without extraction!", archiveFile);
10✔
573
      copy(properInstallDir, targetDir, FileCopyMode.COPY_TREE_CONTENT);
5✔
574
      postExtractHook(postExtractHook, targetDir);
4✔
575
      return;
1✔
576
    } else if (!extract) {
2✔
577
      mkdirs(targetDir);
3✔
578
      move(archiveFile, targetDir.resolve(archiveFile.getFileName()));
9✔
579
      return;
1✔
580
    }
581
    Path tmpDir = createTempDir("extract-" + archiveFile.getFileName());
7✔
582
    this.context.trace("Trying to extract the downloaded file {} to {} and move it to {}.", archiveFile, tmpDir, targetDir);
18✔
583
    String filename = archiveFile.getFileName().toString();
4✔
584
    TarCompression tarCompression = TarCompression.of(filename);
3✔
585
    if (tarCompression != null) {
2✔
586
      extractTar(archiveFile, tmpDir, tarCompression);
6✔
587
    } else {
588
      String extension = FilenameUtil.getExtension(filename);
3✔
589
      if (extension == null) {
2!
590
        throw new IllegalStateException("Unknown archive format without extension - can not extract " + archiveFile);
×
591
      } else {
592
        this.context.trace("Determined file extension {}", extension);
10✔
593
      }
594
      switch (extension) {
8!
595
        case "zip" -> extractZip(archiveFile, tmpDir);
×
596
        case "jar" -> extractJar(archiveFile, tmpDir);
5✔
597
        case "dmg" -> extractDmg(archiveFile, tmpDir);
×
598
        case "msi" -> extractMsi(archiveFile, tmpDir);
×
599
        case "pkg" -> extractPkg(archiveFile, tmpDir);
×
600
        default -> throw new IllegalStateException("Unknown archive format " + extension + ". Can not extract " + archiveFile);
×
601
      }
602
    }
603
    Path properInstallDir = getProperInstallationSubDirOf(tmpDir, archiveFile);
5✔
604
    postExtractHook(postExtractHook, properInstallDir);
4✔
605
    move(properInstallDir, targetDir);
6✔
606
    delete(tmpDir);
3✔
607
  }
1✔
608

609
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
610

611
    if (postExtractHook != null) {
2✔
612
      postExtractHook.accept(properInstallDir);
3✔
613
    }
614
  }
1✔
615

616
  /**
617
   * @param path the {@link Path} to start the recursive search from.
618
   * @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
619
   *     item in their respective directory and {@code s} is not named "bin".
620
   */
621
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
622

623
    try (Stream<Path> stream = Files.list(path)) {
3✔
624
      Path[] subFiles = stream.toArray(Path[]::new);
8✔
625
      if (subFiles.length == 0) {
3!
626
        throw new CliException("The downloaded package " + archiveFile + " seems to be empty as you can check in the extracted folder " + path);
×
627
      } else if (subFiles.length == 1) {
4✔
628
        String filename = subFiles[0].getFileName().toString();
6✔
629
        if (!filename.equals(IdeContext.FOLDER_BIN) && !filename.equals(IdeContext.FOLDER_CONTENTS) && !filename.endsWith(".app") && Files.isDirectory(
19!
630
            subFiles[0])) {
631
          return getProperInstallationSubDirOf(subFiles[0], archiveFile);
9✔
632
        }
633
      }
634
      return path;
4✔
635
    } catch (IOException e) {
4!
636
      throw new IllegalStateException("Failed to get sub-files of " + path);
×
637
    }
638
  }
639

640
  @Override
641
  public void extractZip(Path file, Path targetDir) {
642

643
    this.context.info("Extracting ZIP file {} to {}", file, targetDir);
14✔
644
    URI uri = URI.create("jar:" + file.toUri());
6✔
645
    try (FileSystem fs = FileSystems.newFileSystem(uri, FS_ENV)) {
4✔
646
      long size = 0;
2✔
647
      for (Path root : fs.getRootDirectories()) {
11✔
648
        size += getFileSizeRecursive(root);
6✔
649
      }
1✔
650
      try (final IdeProgressBar progressBar = this.context.newProgressbarForExtracting(size)) {
5✔
651
        for (Path root : fs.getRootDirectories()) {
11✔
652
          copy(root, targetDir, FileCopyMode.EXTRACT, (s, t, d) -> onFileCopiedFromZip(s, t, d, progressBar));
15✔
653
        }
1✔
654
      }
655
    } catch (IOException e) {
×
656
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
657
    }
1✔
658
  }
1✔
659

660
  @SuppressWarnings("unchecked")
661
  private void onFileCopiedFromZip(Path source, Path target, boolean directory, IdeProgressBar progressBar) {
662

663
    if (directory) {
2✔
664
      return;
1✔
665
    }
666
    if (!context.getSystemInfo().isWindows()) {
5✔
667
      try {
668
        Object attribute = Files.getAttribute(source, "zip:permissions");
6✔
669
        if (attribute instanceof Set<?> permissionSet) {
6✔
670
          Files.setPosixFilePermissions(target, (Set<PosixFilePermission>) permissionSet);
4✔
671
        }
672
      } catch (Exception e) {
×
673
        context.error(e, "Failed to transfer zip permissions for {}", target);
×
674
      }
1✔
675
    }
676
    progressBar.stepBy(getFileSize(target));
5✔
677
  }
1✔
678

679
  @Override
680
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
681

682
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
683
  }
1✔
684

685
  @Override
686
  public void extractJar(Path file, Path targetDir) {
687

688
    extractZip(file, targetDir);
4✔
689
  }
1✔
690

691
  /**
692
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
693
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
694
   */
695
  public static String generatePermissionString(int permissions) {
696

697
    // Ensure that only the last 9 bits are considered
698
    permissions &= 0b111111111;
4✔
699

700
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
701
    for (int i = 0; i < 9; i++) {
7✔
702
      int mask = 1 << i;
4✔
703
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
704
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
705
    }
706

707
    return permissionStringBuilder.toString();
3✔
708
  }
709

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

712
    this.context.info("Extracting TAR file {} to {}", file, targetDir);
14✔
713
    try (InputStream is = Files.newInputStream(file);
5✔
714
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
715
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
716

717
      ArchiveEntry entry = ais.getNextEntry();
3✔
718
      boolean isTar = ais instanceof TarArchiveInputStream;
3✔
719
      while (entry != null) {
2✔
720
        String permissionStr = null;
2✔
721
        if (isTar) {
2!
722
          int tarMode = ((TarArchiveEntry) entry).getMode();
4✔
723
          permissionStr = generatePermissionString(tarMode);
3✔
724
        }
725
        Path entryName = Path.of(entry.getName());
6✔
726
        Path entryPath = targetDir.resolve(entryName).toAbsolutePath();
5✔
727
        if (!entryPath.startsWith(targetDir)) {
4!
728
          throw new IOException("Preventing path traversal attack from " + entryName + " to " + entryPath);
×
729
        }
730
        if (entry.isDirectory()) {
3✔
731
          mkdirs(entryPath);
4✔
732
        } else {
733
          // ensure the file can also be created if directory entry was missing or out of order...
734
          mkdirs(entryPath.getParent());
4✔
735
          Files.copy(ais, entryPath);
6✔
736
        }
737
        if (isTar && !this.context.getSystemInfo().isWindows()) {
7!
738
          Set<PosixFilePermission> permissions = PosixFilePermissions.fromString(permissionStr);
3✔
739
          Files.setPosixFilePermissions(entryPath, permissions);
4✔
740
        }
741
        pb.stepBy(entry.getSize());
4✔
742
        entry = ais.getNextEntry();
3✔
743
      }
1✔
744
    } catch (IOException e) {
×
745
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
746
    }
1✔
747
  }
1✔
748

749
  @Override
750
  public void extractDmg(Path file, Path targetDir) {
751

752
    this.context.info("Extracting DMG file {} to {}", file, targetDir);
×
753
    assert this.context.getSystemInfo().isMac();
×
754

755
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
756
    mkdirs(mountPath);
×
757
    ProcessContext pc = this.context.newProcess();
×
758
    pc.executable("hdiutil");
×
759
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
760
    pc.run();
×
761
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
762
    if (appPath == null) {
×
763
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
764
    }
765

766
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
767
    pc.addArgs("detach", "-force", mountPath);
×
768
    pc.run();
×
769
  }
×
770

771
  @Override
772
  public void extractMsi(Path file, Path targetDir) {
773

774
    this.context.info("Extracting MSI file {} to {}", file, targetDir);
×
775
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
776
    // msiexec also creates a copy of the MSI
777
    Path msiCopy = targetDir.resolve(file.getFileName());
×
778
    delete(msiCopy);
×
779
  }
×
780

781
  @Override
782
  public void extractPkg(Path file, Path targetDir) {
783

784
    this.context.info("Extracting PKG file {} to {}", file, targetDir);
×
785
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
786
    ProcessContext pc = this.context.newProcess();
×
787
    // we might also be able to use cpio from commons-compression instead of external xar...
788
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
789
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
790
    extractTar(contentPath, targetDir, TarCompression.GZ);
×
791
    delete(tmpDirPkg);
×
792
  }
×
793

794
  @Override
795
  public void delete(Path path) {
796

797
    if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
798
      this.context.trace("Deleting {} skipped as the path does not exist.", path);
10✔
799
      return;
1✔
800
    }
801
    this.context.debug("Deleting {} ...", path);
10✔
802
    try {
803
      if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
804
        Files.delete(path);
3✔
805
      } else {
806
        deleteRecursive(path);
3✔
807
      }
808
    } catch (IOException e) {
×
809
      throw new IllegalStateException("Failed to delete " + path, e);
×
810
    }
1✔
811
  }
1✔
812

813
  private void deleteRecursive(Path path) throws IOException {
814

815
    if (Files.isDirectory(path)) {
5✔
816
      try (Stream<Path> childStream = Files.list(path)) {
3✔
817
        Iterator<Path> iterator = childStream.iterator();
3✔
818
        while (iterator.hasNext()) {
3✔
819
          Path child = iterator.next();
4✔
820
          deleteRecursive(child);
3✔
821
        }
1✔
822
      }
823
    }
824
    this.context.trace("Deleting {} ...", path);
10✔
825
    boolean isSetWritable = setWritable(path, true);
5✔
826
    if (!isSetWritable) {
2!
827
      this.context.debug("Couldn't give write access to file: " + path);
×
828
    }
829
    Files.delete(path);
2✔
830
  }
1✔
831

832
  @Override
833
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
834

835
    try {
836
      if (!Files.isDirectory(dir)) {
5✔
837
        return null;
2✔
838
      }
839
      return findFirstRecursive(dir, filter, recursive);
6✔
840
    } catch (IOException e) {
×
841
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
842
    }
843
  }
844

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

847
    List<Path> folders = null;
2✔
848
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
849
      Iterator<Path> iterator = childStream.iterator();
3✔
850
      while (iterator.hasNext()) {
3✔
851
        Path child = iterator.next();
4✔
852
        if (filter.test(child)) {
4✔
853
          return child;
4✔
854
        } else if (recursive && Files.isDirectory(child)) {
2!
855
          if (folders == null) {
×
856
            folders = new ArrayList<>();
×
857
          }
858
          folders.add(child);
×
859
        }
860
      }
1✔
861
    }
4!
862
    if (folders != null) {
2!
863
      for (Path child : folders) {
×
864
        Path match = findFirstRecursive(child, filter, recursive);
×
865
        if (match != null) {
×
866
          return match;
×
867
        }
868
      }
×
869
    }
870
    return null;
2✔
871
  }
872

873
  @Override
874
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
875

876
    if (!Files.isDirectory(dir)) {
5✔
877
      return List.of();
2✔
878
    }
879
    List<Path> children = new ArrayList<>();
4✔
880
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
881
      Iterator<Path> iterator = childStream.iterator();
3✔
882
      while (iterator.hasNext()) {
3✔
883
        Path child = iterator.next();
4✔
884
        Path filteredChild = filter.apply(child);
5✔
885
        if (filteredChild != null) {
2✔
886
          if (filteredChild == child) {
3!
887
            this.context.trace("Accepted file {}", child);
11✔
888
          } else {
889
            this.context.trace("Accepted file {} and mapped to {}", child, filteredChild);
×
890
          }
891
          children.add(filteredChild);
5✔
892
        } else {
893
          this.context.trace("Ignoring file {} according to filter", child);
10✔
894
        }
895
      }
1✔
896
    } catch (IOException e) {
×
897
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
898
    }
1✔
899
    return children;
2✔
900
  }
901

902
  @Override
903
  public boolean isEmptyDir(Path dir) {
904

905
    return listChildren(dir, f -> true).isEmpty();
8✔
906
  }
907

908
  private long getFileSize(Path file) {
909

910
    try {
911
      return Files.size(file);
3✔
912
    } catch (IOException e) {
×
913
      this.context.warning(e.getMessage(), e);
×
914
      return 0;
×
915
    }
916
  }
917

918
  private long getFileSizeRecursive(Path path) {
919

920
    long size = 0;
2✔
921
    if (Files.isDirectory(path)) {
5✔
922
      try (Stream<Path> childStream = Files.list(path)) {
3✔
923
        Iterator<Path> iterator = childStream.iterator();
3✔
924
        while (iterator.hasNext()) {
3✔
925
          Path child = iterator.next();
4✔
926
          size += getFileSizeRecursive(child);
6✔
927
        }
1✔
928
      } catch (IOException e) {
×
929
        throw new RuntimeException("Failed to iterate children of folder " + path, e);
×
930
      }
1✔
931
    } else {
932
      size += getFileSize(path);
6✔
933
    }
934
    return size;
2✔
935
  }
936

937
  @Override
938
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
939

940
    for (Path dir : searchDirs) {
10!
941
      Path filePath = dir.resolve(fileName);
4✔
942
      try {
943
        if (Files.exists(filePath)) {
5✔
944
          return filePath;
2✔
945
        }
946
      } catch (Exception e) {
×
947
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
948
      }
1✔
949
    }
1✔
950
    return null;
×
951
  }
952

953
  @Override
954
  public boolean setWritable(Path file, boolean writable) {
955
    try {
956
      // POSIX
957
      PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class);
7✔
958
      if (posix != null) {
2!
959
        Set<PosixFilePermission> permissions = new HashSet<>(posix.readAttributes().permissions());
7✔
960
        boolean changed;
961
        if (writable) {
2!
962
          changed = permissions.add(PosixFilePermission.OWNER_WRITE);
5✔
963
        } else {
964
          changed = permissions.remove(PosixFilePermission.OWNER_WRITE);
×
965
        }
966
        if (changed) {
2!
967
          posix.setPermissions(permissions);
×
968
        }
969
        return true;
2✔
970
      }
971

972
      // Windows
973
      DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class);
×
974
      if (dos != null) {
×
975
        dos.setReadOnly(!writable);
×
976
        return true;
×
977
      }
978

979
      this.context.debug("Failed to set writing permission for file {}", file);
×
980
      return false;
×
981

982
    } catch (IOException e) {
×
983
      this.context.debug("Error occurred when trying to set writing permission for file " + file + ": " + e);
×
984
      return false;
×
985
    }
986
  }
987

988
  @Override
989
  public void makeExecutable(Path file, boolean confirm) {
990

991
    if (Files.exists(file)) {
5✔
992
      if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
993
        this.context.trace("Windows does not have executable flags hence omitting for file {}", file);
×
994
        return;
×
995
      }
996
      try {
997
        // Read the current file permissions
998
        Set<PosixFilePermission> existingPermissions = Files.getPosixFilePermissions(file);
5✔
999

1000
        // Add execute permission for all users
1001
        Set<PosixFilePermission> executablePermissions = new HashSet<>(existingPermissions);
5✔
1002
        boolean update = false;
2✔
1003
        update |= executablePermissions.add(PosixFilePermission.OWNER_EXECUTE);
6✔
1004
        update |= executablePermissions.add(PosixFilePermission.GROUP_EXECUTE);
6✔
1005
        update |= executablePermissions.add(PosixFilePermission.OTHERS_EXECUTE);
6✔
1006

1007
        if (update) {
2✔
1008
          if (confirm) {
2!
1009
            boolean yesContinue = this.context.question(
×
1010
                "We want to execute " + file.getFileName() + " but this command seems to lack executable permissions!\n"
×
1011
                    + "Most probably the tool vendor did forgot to add x-flags in the binary release package.\n"
1012
                    + "Before running the command, we suggest to set executable permissions to the file:\n"
1013
                    + file + "\n"
1014
                    + "For security reasons we ask for your confirmation so please check this request.\n"
1015
                    + "Changing permissions from " + PosixFilePermissions.toString(existingPermissions) + " to " + PosixFilePermissions.toString(
×
1016
                    executablePermissions) + ".\n"
1017
                    + "Do you confirm to make the command executable before running it?");
1018
            if (!yesContinue) {
×
1019
              return;
×
1020
            }
1021
          }
1022
          this.context.debug("Setting executable flags for file {}", file);
10✔
1023
          // Set the new permissions
1024
          Files.setPosixFilePermissions(file, executablePermissions);
5✔
1025
        } else {
1026
          this.context.trace("Executable flags already present so no need to set them for file {}", file);
10✔
1027
        }
1028
      } catch (IOException e) {
×
1029
        throw new RuntimeException(e);
×
1030
      }
1✔
1031
    } else {
1032
      this.context.warning("Cannot set executable flag on file that does not exist: {}", file);
10✔
1033
    }
1034
  }
1✔
1035

1036
  @Override
1037
  public void touch(Path file) {
1038

1039
    if (Files.exists(file)) {
5✔
1040
      try {
1041
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
1042
      } catch (IOException e) {
×
1043
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
1044
      }
1✔
1045
    } else {
1046
      try {
1047
        Files.createFile(file);
5✔
1048
      } catch (IOException e) {
1✔
1049
        throw new IllegalStateException("Could not create empty file " + file, e);
8✔
1050
      }
1✔
1051
    }
1052
  }
1✔
1053

1054
  @Override
1055
  public String readFileContent(Path file) {
1056

1057
    this.context.trace("Reading content of file from {}", file);
10✔
1058
    if (!Files.exists((file))) {
5✔
1059
      this.context.debug("File {} does not exist", file);
10✔
1060
      return null;
2✔
1061
    }
1062
    try {
1063
      String content = Files.readString(file);
3✔
1064
      this.context.trace("Completed reading {} character(s) from file {}", content.length(), file);
16✔
1065
      return content;
2✔
1066
    } catch (IOException e) {
×
1067
      throw new IllegalStateException("Failed to read file " + file, e);
×
1068
    }
1069
  }
1070

1071
  @Override
1072
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1073

1074
    if (createParentDir) {
2✔
1075
      mkdirs(file.getParent());
4✔
1076
    }
1077
    if (content == null) {
2!
1078
      content = "";
×
1079
    }
1080
    this.context.trace("Writing content with {} character(s) to file {}", content.length(), file);
16✔
1081
    if (Files.exists(file)) {
5✔
1082
      this.context.info("Overriding content of file {}", file);
10✔
1083
    }
1084
    try {
1085
      Files.writeString(file, content);
6✔
1086
      this.context.trace("Wrote content to file {}", file);
10✔
1087
    } catch (IOException e) {
×
1088
      throw new RuntimeException("Failed to write file " + file, e);
×
1089
    }
1✔
1090
  }
1✔
1091

1092
  @Override
1093
  public List<String> readFileLines(Path file) {
1094

1095
    this.context.trace("Reading content of file from {}", file);
10✔
1096
    if (!Files.exists(file)) {
5✔
1097
      this.context.warning("File {} does not exist", file);
10✔
1098
      return null;
2✔
1099
    }
1100
    try {
1101
      List<String> content = Files.readAllLines(file);
3✔
1102
      this.context.trace("Completed reading {} lines from file {}", content.size(), file);
16✔
1103
      return content;
2✔
1104
    } catch (IOException e) {
×
1105
      throw new IllegalStateException("Failed to read file " + file, e);
×
1106
    }
1107
  }
1108

1109
  @Override
1110
  public void writeFileLines(List<String> content, Path file, boolean createParentDir) {
1111

1112
    if (createParentDir) {
2!
1113
      mkdirs(file.getParent());
×
1114
    }
1115
    if (content == null) {
2!
1116
      content = List.of();
×
1117
    }
1118
    this.context.trace("Writing content with {} lines to file {}", content.size(), file);
16✔
1119
    if (Files.exists(file)) {
5✔
1120
      this.context.debug("Overriding content of file {}", file);
10✔
1121
    }
1122
    try {
1123
      Files.write(file, content);
6✔
1124
      this.context.trace("Wrote content to file {}", file);
10✔
1125
    } catch (IOException e) {
×
1126
      throw new RuntimeException("Failed to write file " + file, e);
×
1127
    }
1✔
1128
  }
1✔
1129

1130
  @Override
1131
  public void readProperties(Path file, Properties properties) {
1132

1133
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1134
      properties.load(reader);
3✔
1135
      this.context.debug("Successfully loaded {} properties from {}", properties.size(), file);
16✔
1136
    } catch (IOException e) {
×
1137
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1138
    }
1✔
1139
  }
1✔
1140

1141
  @Override
1142
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1143

1144
    if (createParentDir) {
2✔
1145
      mkdirs(file.getParent());
4✔
1146
    }
1147
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1148
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1149
      this.context.debug("Successfully saved {} properties to {}", properties.size(), file);
16✔
1150
    } catch (IOException e) {
×
1151
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1152
    }
1✔
1153
  }
1✔
1154

1155
  @Override
1156
  public Duration getFileAge(Path path) {
1157
    if (Files.exists(path)) {
5✔
1158
      try {
1159
        long currentTime = System.currentTimeMillis();
2✔
1160
        long fileModifiedTime = Files.getLastModifiedTime(path).toMillis();
6✔
1161
        return Duration.ofMillis(currentTime - fileModifiedTime);
5✔
1162
      } catch (IOException e) {
×
1163
        this.context.warning().log(e, "Could not get modification-time of {}.", path);
×
1164
      }
×
1165
    } else {
1166
      this.context.debug("Path {} is missing - skipping modification-time and file age check.", path);
10✔
1167
    }
1168
    return null;
2✔
1169
  }
1170

1171
  @Override
1172
  public boolean isFileAgeRecent(Path path, Duration cacheDuration) {
1173

1174
    Duration age = getFileAge(path);
4✔
1175
    if (age == null) {
2✔
1176
      return false;
2✔
1177
    }
1178
    context.debug("The path {} was last updated {} ago and caching duration is {}.", path, age, cacheDuration);
18✔
1179
    return (age.toMillis() <= cacheDuration.toMillis());
10✔
1180
  }
1181
}
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