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

devonfw / IDEasy / 13519976650

25 Feb 2025 11:17AM UTC coverage: 68.069% (+0.02%) from 68.046%
13519976650

Pull #1078

github

web-flow
Merge 413e61302 into e6cf61504
Pull Request #1078: #929 optimize upgrade settings using file access

2998 of 4849 branches covered (61.83%)

Branch coverage included in aggregate %.

7780 of 10985 relevant lines covered (70.82%)

3.08 hits per line

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

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

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

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

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

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

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

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

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

71
  private final IdeContext context;
72

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

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

84
  private HttpClient createHttpClient(String url) {
85

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

222
  @Override
223
  public boolean exists(Path file) {
224
    return Files.exists(file);
5✔
225
  }
226

227
  @Override
228
  public boolean isExpectedFolder(Path folder) {
229

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

237
  @Override
238
  public String checksum(Path file, String hashAlgorithm) {
239

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

259
  public boolean isJunction(Path path) {
260

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

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

276
  @Override
277
  public Path backup(Path fileOrFolder) {
278

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

299
  private static Path appendParentPath(Path path, Path parent, int max) {
300

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

307
  @Override
308
  public void move(Path source, Path targetDir) {
309

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

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

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

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

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

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

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

404
    assert !(isSymlink && isJunction);
5!
405

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

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

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

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

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

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

483
  @Override
484
  public void symlink(Path source, Path targetLink, boolean relative) {
485

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

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

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

517
  @Override
518
  public Path toRealPath(Path path) {
519

520
    try {
521
      Path realPath = path.toRealPath();
5✔
522
      if (!realPath.equals(path)) {
4✔
523
        this.context.trace("Resolved path {} to {}", path, realPath);
14✔
524
      }
525
      return realPath;
2✔
526
    } catch (IOException e) {
×
527
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
528
    }
529
  }
530

531
  @Override
532
  public Path createTempDir(String name) {
533

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

552
  @Override
553
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
554

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

595
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
596

597
    if (postExtractHook != null) {
2✔
598
      postExtractHook.accept(properInstallDir);
3✔
599
    }
600
  }
1✔
601

602
  /**
603
   * @param path the {@link Path} to start the recursive search from.
604
   * @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
605
   *     item in their respective directory and {@code s} is not named "bin".
606
   */
607
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
608

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

626
  @Override
627
  public void extractZip(Path file, Path targetDir) {
628

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

646
  @SuppressWarnings("unchecked")
647
  private void onFileCopiedFromZip(Path source, Path target, boolean directory, IdeProgressBar progressBar) {
648

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

665
  @Override
666
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
667

668
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
669
  }
1✔
670

671
  @Override
672
  public void extractJar(Path file, Path targetDir) {
673

674
    extractZip(file, targetDir);
4✔
675
  }
1✔
676

677
  /**
678
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
679
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
680
   */
681
  public static String generatePermissionString(int permissions) {
682

683
    // Ensure that only the last 9 bits are considered
684
    permissions &= 0b111111111;
4✔
685

686
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
687
    for (int i = 0; i < 9; i++) {
7✔
688
      int mask = 1 << i;
4✔
689
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
690
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
691
    }
692

693
    return permissionStringBuilder.toString();
3✔
694
  }
695

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

698
    this.context.info("Extracting TAR file {} to {}", file, targetDir);
14✔
699
    try (InputStream is = Files.newInputStream(file);
5✔
700
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
701
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
702

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

735
  @Override
736
  public void extractDmg(Path file, Path targetDir) {
737

738
    this.context.info("Extracting DMG file {} to {}", file, targetDir);
×
739
    assert this.context.getSystemInfo().isMac();
×
740

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

752
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
753
    pc.addArgs("detach", "-force", mountPath);
×
754
    pc.run();
×
755
  }
×
756

757
  @Override
758
  public void extractMsi(Path file, Path targetDir) {
759

760
    this.context.info("Extracting MSI file {} to {}", file, targetDir);
×
761
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
762
    // msiexec also creates a copy of the MSI
763
    Path msiCopy = targetDir.resolve(file.getFileName());
×
764
    delete(msiCopy);
×
765
  }
×
766

767
  @Override
768
  public void extractPkg(Path file, Path targetDir) {
769

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

780
  @Override
781
  public void delete(Path path) {
782

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

799
  private void deleteRecursive(Path path) throws IOException {
800

801
    if (Files.isDirectory(path)) {
5✔
802
      try (Stream<Path> childStream = Files.list(path)) {
3✔
803
        Iterator<Path> iterator = childStream.iterator();
3✔
804
        while (iterator.hasNext()) {
3✔
805
          Path child = iterator.next();
4✔
806
          deleteRecursive(child);
3✔
807
        }
1✔
808
      }
809
    }
810
    this.context.trace("Deleting {} ...", path);
10✔
811
    Files.delete(path);
2✔
812
  }
1✔
813

814
  @Override
815
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
816

817
    try {
818
      if (!Files.isDirectory(dir)) {
5✔
819
        return null;
2✔
820
      }
821
      return findFirstRecursive(dir, filter, recursive);
6✔
822
    } catch (IOException e) {
×
823
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
824
    }
825
  }
826

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

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

855
  @Override
856
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
857

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

884
  @Override
885
  public boolean isEmptyDir(Path dir) {
886

887
    return listChildren(dir, f -> true).isEmpty();
8✔
888
  }
889

890
  private long getFileSize(Path file) {
891

892
    try {
893
      return Files.size(file);
3✔
894
    } catch (IOException e) {
×
895
      this.context.warning(e.getMessage(), e);
×
896
      return 0;
×
897
    }
898
  }
899

900
  private long getFileSizeRecursive(Path path) {
901

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

919
  @Override
920
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
921

922
    for (Path dir : searchDirs) {
×
923
      Path filePath = dir.resolve(fileName);
×
924
      try {
925
        if (Files.exists(filePath)) {
×
926
          return filePath;
×
927
        }
928
      } catch (Exception e) {
×
929
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
930
      }
×
931
    }
×
932
    return null;
×
933
  }
934

935
  @Override
936
  public void makeExecutable(Path file, boolean confirm) {
937

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

947
        // Add execute permission for all users
948
        Set<PosixFilePermission> executablePermissions = new HashSet<>(existingPermissions);
5✔
949
        boolean update = false;
2✔
950
        update |= executablePermissions.add(PosixFilePermission.OWNER_EXECUTE);
6✔
951
        update |= executablePermissions.add(PosixFilePermission.GROUP_EXECUTE);
6✔
952
        update |= executablePermissions.add(PosixFilePermission.OTHERS_EXECUTE);
6✔
953

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

983
  @Override
984
  public void touch(Path file) {
985

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

1001
  @Override
1002
  public String readFileContent(Path file) {
1003

1004
    this.context.trace("Reading content of file from {}", file);
10✔
1005
    try {
1006
      String content = Files.readString(file);
3✔
1007
      this.context.trace("Completed reading {} character(s) from file {}", content.length(), file);
16✔
1008
      return content;
2✔
1009
    } catch (IOException e) {
×
1010
      throw new IllegalStateException("Failed to read file " + file, e);
×
1011
    }
1012
  }
1013

1014
  @Override
1015
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1016

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

1035
  @Override
1036
  public List<String> readFileLines(Path file) {
1037

1038
    this.context.trace("Reading content of file from {}", file);
10✔
1039
    if (!Files.exists(file)) {
5✔
1040
      this.context.warning("File {} does not exist", file);
10✔
1041
      return null;
2✔
1042
    }
1043
    try {
1044
      List<String> content = Files.readAllLines(file);
3✔
1045
      this.context.trace("Completed reading {} lines from file {}", content.size(), file);
16✔
1046
      return content;
2✔
1047
    } catch (IOException e) {
×
1048
      throw new IllegalStateException("Failed to read file " + file, e);
×
1049
    }
1050
  }
1051

1052
  @Override
1053
  public void writeFileLines(List<String> content, Path file, boolean createParentDir) {
1054

1055
    if (createParentDir) {
2!
1056
      mkdirs(file.getParent());
×
1057
    }
1058
    if (content == null) {
2!
1059
      content = List.of();
×
1060
    }
1061
    this.context.trace("Writing content with {} lines to file {}", content.size(), file);
16✔
1062
    if (Files.exists(file)) {
5✔
1063
      this.context.debug("Overriding content of file {}", file);
10✔
1064
    }
1065
    try {
1066
      Files.write(file, content);
6✔
1067
      this.context.trace("Wrote content to file {}", file);
10✔
1068
    } catch (IOException e) {
×
1069
      throw new RuntimeException("Failed to write file " + file, e);
×
1070
    }
1✔
1071
  }
1✔
1072

1073
  @Override
1074
  public void readProperties(Path file, Properties properties) {
1075

1076
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1077
      properties.load(reader);
3✔
1078
      this.context.debug("Successfully loaded {} properties from {}", properties.size(), file);
16✔
1079
    } catch (IOException e) {
×
1080
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1081
    }
1✔
1082
  }
1✔
1083

1084
  @Override
1085
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1086

1087
    if (createParentDir) {
2✔
1088
      mkdirs(file.getParent());
4✔
1089
    }
1090
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1091
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1092
      this.context.debug("Successfully saved {} properties to {}", properties.size(), file);
16✔
1093
    } catch (IOException e) {
×
1094
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1095
    }
1✔
1096
  }
1✔
1097

1098
  @Override
1099
  public Duration getFileAge(Path path) {
1100
    if (Files.exists(path)) {
5✔
1101
      try {
1102
        long currentTime = System.currentTimeMillis();
2✔
1103
        long fileModifiedTime = Files.getLastModifiedTime(path).toMillis();
6✔
1104
        return Duration.ofMillis(currentTime - fileModifiedTime);
5✔
1105
      } catch (IOException e) {
×
1106
        this.context.warning().log(e, "Could not get modification-time of {}.", path);
×
1107
      }
×
1108
    } else {
1109
      this.context.debug("Path {} is missing - skipping modification-time and file age check.", path);
10✔
1110
    }
1111
    return null;
2✔
1112
  }
1113

1114
  @Override
1115
  public boolean isFileAgeRecent(Path path, Duration cacheDuration) {
1116

1117
    Duration age = getFileAge(path);
4✔
1118
    if (age == null) {
2✔
1119
      return false;
2✔
1120
    }
1121
    context.debug("The path {} was last updated {} ago and caching duration is {}.", path, age, cacheDuration);
18✔
1122
    return (age.toMillis() <= cacheDuration.toMillis());
10✔
1123
  }
1124
}
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