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

devonfw / IDEasy / 28850861063

07 Jul 2026 07:58AM UTC coverage: 72.035% (-0.03%) from 72.066%
28850861063

Pull #2062

github

web-flow
Merge bb70870fe into 2cf3f5070
Pull Request #2062: #1982: Modified fallback link creation on windows

4856 of 7440 branches covered (65.27%)

Branch coverage included in aggregate %.

12436 of 16565 relevant lines covered (75.07%)

3.18 hits per line

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

67.83
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.FileOutputStream;
5
import java.io.IOException;
6
import java.io.InputStream;
7
import java.io.OutputStream;
8
import java.io.Reader;
9
import java.io.Writer;
10
import java.net.http.HttpClient.Version;
11
import java.net.http.HttpResponse;
12
import java.nio.charset.StandardCharsets;
13
import java.nio.file.FileSystemException;
14
import java.nio.file.Files;
15
import java.nio.file.LinkOption;
16
import java.nio.file.NoSuchFileException;
17
import java.nio.file.Path;
18
import java.nio.file.StandardCopyOption;
19
import java.nio.file.attribute.BasicFileAttributes;
20
import java.nio.file.attribute.DosFileAttributeView;
21
import java.nio.file.attribute.FileTime;
22
import java.nio.file.attribute.PosixFileAttributeView;
23
import java.nio.file.attribute.PosixFilePermission;
24
import java.security.DigestInputStream;
25
import java.security.MessageDigest;
26
import java.security.NoSuchAlgorithmException;
27
import java.time.Duration;
28
import java.time.LocalDateTime;
29
import java.util.ArrayList;
30
import java.util.Enumeration;
31
import java.util.HashSet;
32
import java.util.Iterator;
33
import java.util.List;
34
import java.util.Objects;
35
import java.util.Properties;
36
import java.util.Set;
37
import java.util.function.Consumer;
38
import java.util.function.Function;
39
import java.util.function.Predicate;
40
import java.util.stream.Stream;
41

42
import org.apache.commons.compress.archivers.ArchiveEntry;
43
import org.apache.commons.compress.archivers.ArchiveInputStream;
44
import org.apache.commons.compress.archivers.ArchiveOutputStream;
45
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
46
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
47
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
48
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
49
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
50
import org.apache.commons.compress.archivers.zip.ZipFile;
51
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream;
52
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
53
import org.apache.commons.compress.compressors.gzip.GzipParameters;
54
import org.apache.commons.io.IOUtils;
55
import org.slf4j.Logger;
56
import org.slf4j.LoggerFactory;
57

58
import com.devonfw.tools.ide.cli.CliException;
59
import com.devonfw.tools.ide.context.IdeContext;
60
import com.devonfw.tools.ide.io.ini.IniComment;
61
import com.devonfw.tools.ide.io.ini.IniFile;
62
import com.devonfw.tools.ide.io.ini.IniSection;
63
import com.devonfw.tools.ide.os.SystemInfoImpl;
64
import com.devonfw.tools.ide.process.ProcessContext;
65
import com.devonfw.tools.ide.process.ProcessMode;
66
import com.devonfw.tools.ide.process.ProcessResult;
67
import com.devonfw.tools.ide.util.DateTimeUtil;
68
import com.devonfw.tools.ide.util.FilenameUtil;
69
import com.devonfw.tools.ide.util.HexUtil;
70
import com.devonfw.tools.ide.variable.IdeVariables;
71

72
/**
73
 * Implementation of {@link FileAccess}.
74
 */
75
public class FileAccessImpl extends HttpDownloader implements FileAccess {
76

77
  private static final Logger LOG = LoggerFactory.getLogger(FileAccessImpl.class);
4✔
78

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

81
  private static final String WINDOWS_FILE_LOCK_WARNING =
82
      "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"
83
          + WINDOWS_FILE_LOCK_DOCUMENTATION_PAGE;
84

85
  private final IdeContext context;
86

87
  /**
88
   * The constructor.
89
   *
90
   * @param context the {@link IdeContext} to use.
91
   */
92
  public FileAccessImpl(IdeContext context) {
93

94
    super();
2✔
95
    this.context = context;
3✔
96
  }
1✔
97

98
  @Override
99
  public void download(String url, Path target) {
100

101
    if (url.startsWith("http")) {
4✔
102
      downloadViaHttp(url, target);
5✔
103
    } else if (url.startsWith("ftp") || url.startsWith("sftp")) {
8!
104
      throw new IllegalArgumentException("Unsupported download URL: " + url);
×
105
    } else {
106
      Path source = Path.of(url);
5✔
107
      if (isFile(source)) {
4!
108
        // network drive
109
        copyFileWithProgressBar(source, target);
5✔
110
      } else {
111
        throw new IllegalArgumentException("Download path does not point to a downloadable file: " + url);
×
112
      }
113
    }
114
  }
1✔
115

116
  private void downloadViaHttp(String url, Path target) {
117

118
    List<Version> httpProtocols = IdeVariables.HTTP_VERSIONS.get(this.context);
6✔
119
    Exception lastException = null;
2✔
120
    if (httpProtocols.isEmpty()) {
3!
121
      try {
122
        downloadWithHttpVersion(url, target, null);
5✔
123
        return;
1✔
124
      } catch (Exception e) {
×
125
        lastException = e;
×
126
      }
×
127
    } else {
128
      for (Version version : httpProtocols) {
×
129
        try {
130
          downloadWithHttpVersion(url, target, version);
×
131
          return;
×
132
        } catch (Exception ex) {
×
133
          lastException = ex;
×
134
        }
135
      }
×
136
    }
137
    throw new IllegalStateException("Failed to download file from URL " + url + " to " + target, lastException);
×
138
  }
139

140
  private void downloadWithHttpVersion(String url, Path target, Version httpVersion) throws Exception {
141

142
    if (httpVersion == null) {
2!
143
      LOG.info("Trying to download {} from {}", target.getFileName(), url);
7✔
144
    } else {
145
      LOG.info("Trying to download {} from {} with HTTP protocol version {}", target.getFileName(), url, httpVersion);
×
146
    }
147
    mkdirs(target.getParent());
4✔
148
    this.context.getNetworkStatus().invokeNetworkTask(() ->
11✔
149
    {
150
      httpGet(url, httpVersion, (response) -> downloadFileWithProgressBar(url, target, response));
13✔
151
      return null;
2✔
152
    }, url);
153
  }
1✔
154

155
  /**
156
   * Downloads a file while showing a {@link IdeProgressBar}.
157
   *
158
   * @param url the url to download.
159
   * @param target Path of the target directory.
160
   * @param response the {@link HttpResponse} to use.
161
   */
162
  private void downloadFileWithProgressBar(String url, Path target, HttpResponse<InputStream> response) {
163

164
    long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1);
7✔
165
    if (contentLength < 0) {
4✔
166
      LOG.warn("Content-Length was not provided by download from {}", url);
4✔
167
    }
168

169
    byte[] data = new byte[1024];
3✔
170
    boolean fileComplete = false;
2✔
171
    int count;
172

173
    try (InputStream body = response.body();
4✔
174
        FileOutputStream fileOutput = new FileOutputStream(target.toFile());
6✔
175
        BufferedOutputStream bufferedOut = new BufferedOutputStream(fileOutput, data.length);
7✔
176
        IdeProgressBar pb = this.context.newProgressBarForDownload(contentLength)) {
5✔
177
      while (!fileComplete) {
2✔
178
        count = body.read(data);
4✔
179
        if (count <= 0) {
2✔
180
          fileComplete = true;
3✔
181
        } else {
182
          bufferedOut.write(data, 0, count);
5✔
183
          pb.stepBy(count);
5✔
184
        }
185
      }
186
    } catch (Exception e) {
×
187
      throw new RuntimeException(e);
×
188
    }
1✔
189
  }
1✔
190

191
  private void copyFileWithProgressBar(Path source, Path target) {
192

193
    long size = getFileSize(source);
4✔
194
    if (size < 100_000) {
4✔
195
      copy(source, target, FileCopyMode.COPY_FILE_TO_TARGET_OVERRIDE);
5✔
196
      return;
1✔
197
    }
198
    try (InputStream in = Files.newInputStream(source); OutputStream out = Files.newOutputStream(target)) {
10✔
199
      byte[] buf = new byte[1024];
3✔
200
      try (IdeProgressBar pb = this.context.newProgressbarForCopying(size)) {
5✔
201
        int readBytes;
202
        while ((readBytes = in.read(buf)) > 0) {
6✔
203
          out.write(buf, 0, readBytes);
5✔
204
          pb.stepBy(readBytes);
5✔
205
        }
206
      } catch (Exception e) {
×
207
        throw new RuntimeException(e);
×
208
      }
1✔
209
    } catch (IOException e) {
×
210
      throw new RuntimeException("Failed to copy from " + source + " to " + target, e);
×
211
    }
1✔
212
  }
1✔
213

214
  @Override
215
  public String download(String url) {
216

217
    LOG.debug("Downloading text body from {}", url);
4✔
218
    return httpGetAsString(url);
3✔
219
  }
220

221
  @Override
222
  public void mkdirs(Path directory) {
223

224
    if (Files.isDirectory(directory)) {
5✔
225
      return;
1✔
226
    }
227
    LOG.trace("Creating directory {}", directory);
4✔
228
    try {
229
      Files.createDirectories(directory);
5✔
230
    } catch (IOException e) {
1✔
231
      throw new IllegalStateException("Failed to create directory " + directory, e);
8✔
232
    }
1✔
233
  }
1✔
234

235
  @Override
236
  public boolean isFile(Path file) {
237

238
    if (!Files.exists(file)) {
5!
239
      LOG.trace("File {} does not exist", file);
×
240
      return false;
×
241
    }
242
    if (Files.isDirectory(file)) {
5!
243
      LOG.trace("Path {} is a directory but a regular file was expected", file);
×
244
      return false;
×
245
    }
246
    return true;
2✔
247
  }
248

249
  @Override
250
  public boolean isExpectedFolder(Path folder) {
251

252
    if (Files.isDirectory(folder)) {
5✔
253
      return true;
2✔
254
    }
255
    LOG.warn("Expected folder was not found at {}", folder);
4✔
256
    return false;
2✔
257
  }
258

259
  @Override
260
  public String checksum(Path file, String hashAlgorithm) {
261

262
    MessageDigest md;
263
    try {
264
      md = MessageDigest.getInstance(hashAlgorithm);
3✔
265
    } catch (NoSuchAlgorithmException e) {
×
266
      throw new IllegalStateException("No such hash algorithm " + hashAlgorithm, e);
×
267
    }
1✔
268
    byte[] buffer = new byte[1024];
3✔
269
    try (InputStream is = Files.newInputStream(file); DigestInputStream dis = new DigestInputStream(is, md)) {
11✔
270
      int read = 0;
2✔
271
      while (read >= 0) {
2✔
272
        read = dis.read(buffer);
5✔
273
      }
274
    } catch (Exception e) {
×
275
      throw new IllegalStateException("Failed to read and hash file " + file, e);
×
276
    }
1✔
277
    byte[] digestBytes = md.digest();
3✔
278
    return HexUtil.toHexString(digestBytes);
3✔
279
  }
280

281
  @Override
282
  public boolean isJunction(Path path) {
283

284
    if (!SystemInfoImpl.INSTANCE.isWindows()) {
3!
285
      return false;
2✔
286
    }
287
    try {
288
      BasicFileAttributes attr = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
×
289
      return attr.isOther() && attr.isDirectory();
×
290
    } catch (NoSuchFileException e) {
×
291
      return false; // file doesn't exist
×
292
    } catch (IOException e) {
×
293
      // For broken junctions, reading attributes might fail with various IOExceptions.
294
      // In such cases, we should check if the path exists without following links.
295
      // If it exists but we can't read its attributes, it's likely a broken junction.
296
      if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
×
297
        LOG.debug("Path {} exists but attributes cannot be read, likely a broken junction: {}", path, e.getMessage());
×
298
        return true; // Assume it's a broken junction
×
299
      }
300
      // If it doesn't exist at all, it's not a junction
301
      return false;
×
302
    }
303
  }
304

305
  @Override
306
  public Path backup(Path fileOrFolder) {
307

308
    if ((fileOrFolder != null) && (Files.isSymbolicLink(fileOrFolder) || isJunction(fileOrFolder))) {
9!
309
      delete(fileOrFolder);
4✔
310
    } else if ((fileOrFolder != null) && Files.exists(fileOrFolder)) {
7!
311
      LOG.trace("Going to backup {}", fileOrFolder);
4✔
312
      LocalDateTime now = LocalDateTime.now();
2✔
313
      String date = DateTimeUtil.formatDate(now, true);
4✔
314
      String time = DateTimeUtil.formatTime(now);
3✔
315
      String filename = fileOrFolder.getFileName().toString();
4✔
316
      Path backupBaseDir = this.context.getIdeHome();
4✔
317
      if (backupBaseDir == null) {
2!
318
        backupBaseDir = this.context.getIdePath();
×
319
      }
320
      Path backupPath = backupBaseDir.resolve(IdeContext.FOLDER_BACKUPS).resolve(date).resolve(time + "_" + filename);
10✔
321
      backupPath = appendParentPath(backupPath, fileOrFolder.getParent(), 2);
6✔
322
      mkdirs(backupPath);
3✔
323
      Path target = backupPath.resolve(filename);
4✔
324
      LOG.info("Creating backup by moving {} to {}", fileOrFolder, target);
5✔
325
      move(fileOrFolder, target);
6✔
326
      return target;
2✔
327
    } else {
328
      LOG.trace("Backup of {} skipped as the path does not exist.", fileOrFolder);
4✔
329
    }
330
    return fileOrFolder;
2✔
331
  }
332

333
  private static Path appendParentPath(Path path, Path parent, int max) {
334

335
    if ((parent == null) || (max <= 0)) {
4!
336
      return path;
2✔
337
    }
338
    return appendParentPath(path, parent.getParent(), max - 1).resolve(parent.getFileName());
11✔
339
  }
340

341
  @Override
342
  public void move(Path source, Path targetDir, StandardCopyOption... copyOptions) {
343

344
    LOG.trace("Moving {} to {}", source, targetDir);
5✔
345
    try {
346
      Files.move(source, targetDir, copyOptions);
5✔
347
    } catch (IOException e) {
×
348
      String fileType = Files.isSymbolicLink(source) ? "symlink" : isJunction(source) ? "junction" : Files.isDirectory(source) ? "directory" : "file";
×
349
      String message = "Failed to move " + fileType + ": " + source + " to " + targetDir + ".";
×
350
      if (this.context.getSystemInfo().isWindows()) {
×
351
        message = message + "\n" + WINDOWS_FILE_LOCK_WARNING;
×
352
      }
353
      throw new IllegalStateException(message, e);
×
354
    }
1✔
355
  }
1✔
356

357
  @Override
358
  public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) {
359

360
    if (mode.isUseSourceFilename()) {
3✔
361
      // if we want to copy the file or folder "source" to the existing folder "target" in a shell this will copy
362
      // source into that folder so that we as a result have a copy in "target/source".
363
      // With Java NIO the raw copy method will fail as we cannot copy "source" to the path of the "target" folder.
364
      // For folders we want the same behavior as the linux "cp -r" command so that the "source" folder is copied
365
      // and not only its content what also makes it consistent with the move method that also behaves this way.
366
      // Therefore we need to add the filename (foldername) of "source" to the "target" path before.
367
      // For the rare cases, where we want to copy the content of a folder (cp -r source/* target) we support
368
      // it via the COPY_TREE_CONTENT mode.
369
      Path fileName = source.getFileName();
3✔
370
      if (fileName != null) { // if filename is null, we are copying the root of a (virtual filesystem)
2!
371
        target = target.resolve(fileName.toString());
5✔
372
      }
373
    }
374
    boolean fileOnly = mode.isFileOnly();
3✔
375
    String operation = mode.getOperation();
3✔
376
    if (mode.isExtract()) {
3!
377
      LOG.debug("Starting to {} to {}", operation, target);
×
378
    } else {
379
      if (fileOnly) {
2✔
380
        LOG.debug("Starting to {} file {} to {}", operation, source, target);
18✔
381
      } else {
382
        LOG.debug("Starting to {} {} recursively to {}", operation, source, target);
17✔
383
      }
384
    }
385
    if (fileOnly && Files.isDirectory(source)) {
7!
386
      throw new IllegalStateException("Expected file but found a directory to copy at " + source);
×
387
    }
388
    if (mode.isFailIfExists()) {
3✔
389
      if (Files.exists(target)) {
5!
390
        throw new IllegalStateException("Failed to " + operation + " " + source + " to already existing target " + target);
×
391
      }
392
    } else if (mode == FileCopyMode.COPY_TREE_OVERRIDE_TREE) {
3✔
393
      delete(target);
3✔
394
    }
395
    try {
396
      copyRecursive(source, target, mode, listener);
6✔
397
    } catch (IOException e) {
×
398
      throw new IllegalStateException("Failed to " + operation + " " + source + " to " + target, e);
×
399
    }
1✔
400
  }
1✔
401

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

404
    if (Files.isDirectory(source)) {
5✔
405
      mkdirs(target);
3✔
406
      try (Stream<Path> childStream = Files.list(source)) {
3✔
407
        Iterator<Path> iterator = childStream.iterator();
3✔
408
        while (iterator.hasNext()) {
3✔
409
          Path child = iterator.next();
4✔
410
          copyRecursive(child, target.resolve(child.getFileName().toString()), mode, listener);
10✔
411
        }
1✔
412
      }
413
      listener.onCopy(source, target, true);
6✔
414
    } else if (Files.exists(source)) {
5!
415
      if (mode.isOverride()) {
3✔
416
        delete(target);
3✔
417
      }
418
      LOG.trace("Starting to {} {} to {}", mode.getOperation(), source, target);
18✔
419
      Files.copy(source, target);
6✔
420
      listener.onCopy(source, target, false);
6✔
421
    } else {
422
      throw new IOException("Path " + source + " does not exist.");
×
423
    }
424
  }
1✔
425

426
  /**
427
   * Deletes the given {@link Path} if it is a symbolic link or a Windows junction or hard link. And throws an {@link IllegalStateException} if it fails to
428
   * delete the file at the given {@link Path}.
429
   *
430
   * @param path the {@link Path} to delete.
431
   */
432
  private void deleteLinkIfExists(Path path) {
433

434
    boolean isJunction = isJunction(path); // since broken junctions are not detected by Files.exists()
4✔
435
    boolean exists = Files.exists(path, LinkOption.NOFOLLOW_LINKS);
9✔
436

437
    if (isJunction || exists) {
4!
438
      LOG.info("Deleting previous link or file at " + path);
5✔
439
      try {
440
        Files.delete(path);
2✔
441
      } catch (IOException e) {
×
442
        throw new IllegalStateException("Failed to delete link or file at " + path, e);
×
443
      }
1✔
444
    }
445
  }
1✔
446

447
  /**
448
   * Computes the relative target {@link Path} to use for a relative symbolic link at {@code link} pointing to {@code source}.
449
   *
450
   * @param source the {@link Path} the link should point to.
451
   * @param link the absolute, normalized destination {@link Path} of the link.
452
   * @return the relative {@link Path} from the parent of {@code link} to {@code source}.
453
   */
454
  private Path relativizeSource(Path source, Path link) {
455

456
    if (!source.isAbsolute()) {
3✔
457
      source = link.resolveSibling(source);
4✔
458
    }
459
    source = source.toAbsolutePath().normalize();
4✔
460
    return preserveSelfReference(link.getParent().relativize(source));
6✔
461
  }
462

463
  /**
464
   * {@link Path#normalize()} and {@link Path#relativize(Path)} can produce the empty path when the result would be the current directory. {@code "."} is the
465
   * usable equivalent for a symbolic link target.
466
   */
467
  private static Path preserveSelfReference(Path path) {
468

469
    return path.toString().isEmpty() ? Path.of(".") : path;
11✔
470
  }
471

472
  /**
473
   * Creates a Windows link using mklink at {@code link} pointing to {@code target}.
474
   *
475
   * @param source the {@link Path} the link will point to.
476
   * @param link the {@link Path} where to create the link.
477
   * @param type the {@link PathLinkType}.
478
   */
479
  private void mklinkOnWindows(Path source, Path absoluteSource, Path link, PathLinkType type, boolean relative) {
480

481
    Path finalSource = relative ? source : absoluteSource;
×
482
    Path finalLink = link;
×
483
    Path cwd = null;
×
484
    if (relative) {
×
485
      int count = getCommonNameCount(absoluteSource, link);
×
486
      if (count < 1) {
×
487
        LOG.error("Cannot create relative link at {} pointing to {} - falling back to absolute link", link, absoluteSource);
×
488
      } else {
489
        cwd = getPathStart(absoluteSource, count - 1);
×
490
        finalSource = getPathEnd(absoluteSource, count);
×
491
        finalLink = getPathEnd(link, count);
×
492
      }
493
    }
494

495
    String option = type.getMklinkOption();
×
496
    if (type == PathLinkType.SYMBOLIC_LINK) {
×
497
      boolean directoryTarget = Files.isDirectory(absoluteSource);
×
498
      option = directoryTarget ? "/j" : "/d";
×
499
    } else {
×
500
      // Manual override of option in case of HARD_LINK, because of missing permissions for `mklink /h ...`
501
      option = "/j";
×
502
    }
503

504
    if (!runMklink(finalSource, finalLink, cwd, option)) {
×
505
      throw new IllegalStateException("Failed to create Windows link at " + link + " pointing to " + source);
×
506
    }
507
  }
×
508

509
  private boolean runMklink(Path source, Path link, Path cwd, String option) {
510

511
    LOG.trace("Creating a Windows link with mklink {} at {} pointing to {}", option, link, source);
×
512
    ProcessContext pc = this.context.newProcess().executable("cmd").addArgs("/c", "mklink", option);
×
513
    if (cwd != null) {
×
514
      pc.directory(cwd);
×
515
    }
516

517
    try {
518
      ProcessContext context = pc.addArgs(link.toString(), source.toString());
×
519
      ProcessResult result = context.run(ProcessMode.DEFAULT);
×
520
      result.failOnError();
×
521
      return true;
×
522
    } catch (RuntimeException e) {
×
523
      LOG.debug("mklink {} failed for {} -> {}: {}", option, link, source, e.getMessage());
×
524
      return false;
×
525
    }
526
  }
527

528
  @Override
529
  public PathLinkType link(Path source, Path link, boolean relative, PathLinkType type) {
530
    PathLinkType resultingPathLinkType = null;
2✔
531
    Path absoluteLink = link.toAbsolutePath().normalize();
4✔
532
    // Keep this lexical only: archive symlinks may point through links that are created later.
533
    Path finalSource = relative ? relativizeSource(source, absoluteLink) : preserveSelfReference(source.normalize());
11✔
534
    Path absoluteSource = finalSource.isAbsolute() ? finalSource : absoluteLink.getParent().resolve(finalSource).normalize();
11✔
535
    LOG.debug("Creating {} at {} pointing to {} (relative={})", type, link, finalSource, relative);
22✔
536
    deleteLinkIfExists(link);
3✔
537
    try {
538
      // Attention: JavaDoc and position of path arguments can be very confusing - see comment in #1736
539
      if (type == PathLinkType.SYMBOLIC_LINK) {
3!
540
        Files.createSymbolicLink(link, finalSource);
6✔
541
        resultingPathLinkType = PathLinkType.SYMBOLIC_LINK;
3✔
542
      } else if (type == PathLinkType.HARD_LINK) {
×
543
        createHardLink(absoluteSource, link);
×
544
        resultingPathLinkType = PathLinkType.HARD_LINK;
×
545
      } else {
546
        throw new IllegalStateException("" + type);
×
547
      }
548
    } catch (FileSystemException | RuntimeException e) {
×
549
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
550
        if (Files.isDirectory(absoluteSource)) {
×
551
          mklinkOnWindows(finalSource, absoluteSource, absoluteLink, type, relative);
×
552
          LOG.info("Created junction with mklink as fallback for link to directory.");
×
553
        } else {
554
          createHardLink(absoluteSource, link);
×
555
          resultingPathLinkType = PathLinkType.HARD_LINK;
×
556
          LOG.info("Created hard link as fallback for link to file.");
×
557
        }
558
      } else {
559
        throw new RuntimeException(e);
×
560
      }
561
    } catch (IOException e) {
×
562
      throw new IllegalStateException("Failed to create " + type + " at " + link + " pointing to " + source + " (relative=" + relative + ")", e);
×
563
    }
1✔
564
    return resultingPathLinkType;
2✔
565
  }
566

567

568
  /**
569
   * Creates a hard link at {@code link} pointing to {@code source}.
570
   *
571
   * @param source the {@link Path} the hard link will point to.
572
   * @param link the {@link Path} where to create the hard link.
573
   */
574
  void createHardLink(Path source, Path link) {
575
    try {
576
      Files.createLink(link, source);
×
577
      LOG.trace("Created hard link at {} pointing to {}", link, source);
×
578
    } catch (IOException e) {
×
579
      throw new RuntimeException("Failed to create a hardlink for " + source + " at " + link, e);
×
580
    }
×
581
  }
×
582

583
  @Override
584
  public Path getPathStart(Path path, int nameEnd) {
585

586
    int count = path.getNameCount();
3✔
587
    int delta = count - nameEnd - 1;
6✔
588
    if (delta < 0) {
2!
589
      throw new IllegalArgumentException("Cannot get start before segment " + nameEnd + " from path " + path + " that has only " + count + " segments.");
×
590
    }
591
    Path result = path;
2✔
592
    while (delta > 0) {
2✔
593
      result = result.getParent();
3✔
594
      delta--;
2✔
595
    }
596
    return result;
2✔
597
  }
598

599
  @Override
600
  public Path getPathEnd(Path path, int nameStart) {
601

602
    int count = path.getNameCount();
3✔
603
    if (nameStart > count) {
3!
604
      throw new IllegalArgumentException("Cannot get end after segment " + nameStart + " from path " + path + " that has only " + count + " segments.");
×
605
    } else if (nameStart == count) {
3!
606
      return Path.of(".");
×
607
    }
608
    Path result = path.getName(nameStart++);
5✔
609
    while (nameStart < count) {
3✔
610
      result = result.resolve(path.getName(nameStart++));
8✔
611
    }
612
    return result;
2✔
613
  }
614

615
  @Override
616
  public int getCommonNameCount(Path path1, Path path2) {
617

618
    if ((path1 == null) || (path2 == null) || !Objects.equals(path1.getRoot(), path2.getRoot())) {
10✔
619
      return -1;
2✔
620
    }
621
    int minNameCount = Math.min(path1.getNameCount(), path2.getNameCount());
6✔
622
    int i = 0;
2✔
623
    while (i < minNameCount) {
3!
624
      Path segment1 = path1.getName(i);
4✔
625
      Path segment2 = path2.getName(i);
4✔
626
      if (!segment1.equals(segment2)) {
4✔
627
        break;
1✔
628
      }
629
      i++;
1✔
630
    }
1✔
631
    return i;
2✔
632
  }
633

634
  @Override
635
  public Path toRealPath(Path path) {
636

637
    return toRealPath(path, true);
5✔
638
  }
639

640
  @Override
641
  public Path toCanonicalPath(Path path) {
642

643
    return toRealPath(path, false);
5✔
644
  }
645

646
  private Path toRealPath(Path path, boolean resolveLinks) {
647

648
    try {
649
      Path realPath;
650
      if (resolveLinks) {
2✔
651
        realPath = path.toRealPath();
6✔
652
      } else {
653
        realPath = path.toRealPath(LinkOption.NOFOLLOW_LINKS);
9✔
654
      }
655
      if (!realPath.equals(path)) {
4✔
656
        LOG.trace("Resolved path {} to {}", path, realPath);
5✔
657
      }
658
      return realPath;
2✔
659
    } catch (IOException e) {
×
660
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
661
    }
662
  }
663

664
  @Override
665
  public Path createTempDir(String name) {
666

667
    try {
668
      Path tmp = this.context.getTempPath();
4✔
669
      Path tempDir = tmp.resolve(name);
4✔
670
      int tries = 1;
2✔
671
      while (Files.exists(tempDir)) {
5!
672
        long id = System.nanoTime() & 0xFFFF;
×
673
        tempDir = tmp.resolve(name + "-" + id);
×
674
        tries++;
×
675
        if (tries > 200) {
×
676
          throw new IOException("Unable to create unique name!");
×
677
        }
678
      }
×
679
      return Files.createDirectory(tempDir);
5✔
680
    } catch (IOException e) {
×
681
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
682
    }
683
  }
684

685
  @Override
686
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
687

688
    if (Files.isDirectory(archiveFile)) {
5✔
689
      LOG.warn("Found directory for download at {} hence copying without extraction!", archiveFile);
4✔
690
      copy(archiveFile, targetDir, FileCopyMode.COPY_TREE_CONTENT);
5✔
691
      postExtractHook(postExtractHook, targetDir);
4✔
692
      return;
1✔
693
    } else if (!extract) {
2✔
694
      mkdirs(targetDir);
3✔
695
      move(archiveFile, targetDir.resolve(archiveFile.getFileName()));
9✔
696
      return;
1✔
697
    }
698
    Path tmpDir = createTempDir("extract-" + archiveFile.getFileName());
7✔
699
    LOG.trace("Trying to extract the file {} to {} and move it to {}.", archiveFile, tmpDir, targetDir);
17✔
700
    String filename = archiveFile.getFileName().toString();
4✔
701
    TarCompression tarCompression = TarCompression.of(filename);
3✔
702
    if (tarCompression != null) {
2✔
703
      extractTar(archiveFile, tmpDir, tarCompression);
6✔
704
    } else {
705
      String extension = FilenameUtil.getExtension(filename);
3✔
706
      if (extension == null) {
2!
707
        throw new IllegalStateException("Unknown archive format without extension - can not extract " + archiveFile);
×
708
      } else {
709
        LOG.trace("Determined file extension {}", extension);
4✔
710
      }
711
      switch (extension) {
8!
712
        case "zip" -> extractZip(archiveFile, tmpDir);
×
713
        case "jar" -> extractJar(archiveFile, tmpDir);
5✔
714
        case "dmg" -> extractDmg(archiveFile, tmpDir);
×
715
        case "msi" -> extractMsi(archiveFile, tmpDir);
×
716
        case "pkg" -> extractPkg(archiveFile, tmpDir);
×
717
        default -> throw new IllegalStateException("Unknown archive format " + extension + ". Can not extract " + archiveFile);
×
718
      }
719
    }
720
    Path properInstallDir = getProperInstallationSubDirOf(tmpDir, archiveFile);
5✔
721
    postExtractHook(postExtractHook, properInstallDir);
4✔
722
    move(properInstallDir, targetDir);
6✔
723
    delete(tmpDir);
3✔
724
  }
1✔
725

726
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
727

728
    if (postExtractHook != null) {
2✔
729
      postExtractHook.accept(properInstallDir);
3✔
730
    }
731
  }
1✔
732

733
  /**
734
   * @param path the {@link Path} to start the recursive search from.
735
   * @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
736
   *     item in their respective directory and {@code s} is not named "bin".
737
   */
738
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
739

740
    try (Stream<Path> stream = Files.list(path)) {
3✔
741
      Path[] subFiles = stream.toArray(Path[]::new);
8✔
742
      if (subFiles.length == 0) {
3!
743
        throw new CliException("The downloaded package " + archiveFile + " seems to be empty as you can check in the extracted folder " + path);
×
744
      } else if (subFiles.length == 1) {
4✔
745
        String filename = subFiles[0].getFileName().toString();
6✔
746
        if (!filename.equals(IdeContext.FOLDER_BIN) && !filename.equals(IdeContext.FOLDER_CONTENTS) && !filename.endsWith(".app") && Files.isDirectory(
19!
747
            subFiles[0])) {
748
          return getProperInstallationSubDirOf(subFiles[0], archiveFile);
9✔
749
        }
750
      }
751
      return path;
4✔
752
    } catch (IOException e) {
4!
753
      throw new IllegalStateException("Failed to get sub-files of " + path);
×
754
    }
755
  }
756

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

760
    LOG.info("Extracting ZIP file {} to {}", file, targetDir);
5✔
761
    extractZipWithJava(file, targetDir);
4✔
762
  }
1✔
763

764
  /**
765
   * Extracts a ZIP archive to the given target directory using Java (commons-compress {@link ZipFile}).
766
   * <p>
767
   * Symlinks are handled in a two-phase approach: all regular files and directories are written first, and symlinks are collected and created afterwards.
768
   * Creating the links does not require resolving their targets, so chained macOS {@code .framework} links can be restored.
769
   * <p>
770
   * {@link ZipFile} is used instead of {@link org.apache.commons.compress.archivers.zip.ZipArchiveInputStream} because Unix file attributes (needed for symlink
771
   * detection and permission restoration) are stored in the ZIP central directory at the end of the file. A sequential stream only sees the local file headers,
772
   * where external attributes are always zero. {@link ZipFile} reads the central directory via random access, so attributes are always correct.
773
   *
774
   * @param file the ZIP archive to extract.
775
   * @param targetDir the directory to extract into.
776
   */
777
  private void extractZipWithJava(Path file, Path targetDir) {
778

779
    final List<PathLink> links = new ArrayList<>();
4✔
780
    try (ZipFile zipFile = ZipFile.builder().setPath(file).get();
6✔
781
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
782

783
      final Path root = targetDir.toAbsolutePath().normalize();
4✔
784
      Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
3✔
785

786
      while (entries.hasMoreElements()) {
3✔
787
        ZipArchiveEntry entry = entries.nextElement();
4✔
788
        String entryName = entry.getName();
3✔
789
        Path entryPath = resolveRelativePathSecure(entryName, root);
5✔
790

791
        if (isZipSymlink(entry)) {
3✔
792
          try (InputStream entryStream = zipFile.getInputStream(entry)) {
5✔
793
            // for a symlink entry the file content is the link target path (Unix ZIP convention)
794
            String linkTarget = IOUtils.toString(entryStream, StandardCharsets.UTF_8);
4✔
795
            Path parent = entryPath.getParent();
3✔
796
            resolveRelativePathSecure(parent.resolve(linkTarget).normalize(), root, linkTarget);
9✔
797
            // preserve the raw target so chained links resolve once the bundle is fully extracted
798
            links.add(new PathLink(Path.of(linkTarget), entryPath, PathLinkType.SYMBOLIC_LINK));
12✔
799
            mkdirs(parent);
3✔
800
          }
801
        } else if (entry.isDirectory()) {
3✔
802
          mkdirs(entryPath);
4✔
803
        } else {
804
          mkdirs(entryPath.getParent());
4✔
805
          try (InputStream entryStream = zipFile.getInputStream(entry)) {
4✔
806
            Files.copy(entryStream, entryPath, StandardCopyOption.REPLACE_EXISTING);
10✔
807
          }
808
          onFileCopiedFromZip(entry, entryPath);
4✔
809
        }
810
        pb.stepBy(Math.max(0L, entry.getSize()));
6✔
811
      }
1✔
812

813
      // Phase 2: create all symlinks after regular files and directories have been extracted.
814
      for (PathLink link : links) {
10✔
815
        link(link);
4✔
816
      }
1✔
817
    } catch (IOException e) {
×
818
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
819
    }
1✔
820
  }
1✔
821

822
  /**
823
   * Returns the Unix file mode stored in the external file attributes of the given ZIP entry.
824
   * <p>
825
   * The ZIP specification stores platform-specific metadata in a 32-bit external-attributes field. When the archive was created on a Unix system the layout
826
   * is:
827
   * <ul>
828
   *   <li>bits 31–16 (upper 16 bits): Unix file mode (same bit layout as {@code stat.st_mode})</li>
829
   *   <li>bits 15–0 (lower 16 bits): MS-DOS attributes (read-only, hidden, …)</li>
830
   * </ul>
831
   * Shifting right by 16 and masking with {@code 0xFFFF} isolates the Unix mode.
832
   *
833
   * @param entry the ZIP entry to read.
834
   * @return the Unix file mode, or {@code 0} if no Unix attributes are present.
835
   */
836
  private static int getZipUnixMode(ZipArchiveEntry entry) {
837

838
    // Right-shift by 16 to move the upper 16 bits (Unix mode) into the lower 16 bits, then
839
    // mask with 0xFFFF to discard any sign-extended bits introduced by the cast.
840
    return (int) ((entry.getExternalAttributes() >> 16) & 0xFFFF);
8✔
841
  }
842

843
  /**
844
   * Returns {@code true} if the given ZIP entry represents a symbolic link.
845
   * <p>
846
   * Unix file modes encode the file type in the top 4 bits (bits 15–12) of the mode word. The possible file-type values are defined in {@code <sys/stat.h>}:
847
   * <ul>
848
   *   <li>{@code 0x8000} – regular file ({@code S_IFREG})</li>
849
   *   <li>{@code 0x4000} – directory ({@code S_IFDIR})</li>
850
   *   <li>{@code 0xA000} – symbolic link ({@code S_IFLNK})</li>
851
   * </ul>
852
   * Masking with {@code 0xF000} isolates those top 4 bits and comparing against {@code 0xA000} identifies symlinks.
853
   *
854
   * @param entry the ZIP entry to test.
855
   * @return {@code true} if the entry is a symbolic link, {@code false} otherwise.
856
   */
857
  private static boolean isZipSymlink(ZipArchiveEntry entry) {
858

859
    // 0xF000 masks the file-type nibble; 0xA000 is the Unix S_IFLNK constant.
860
    return (getZipUnixMode(entry) & 0xF000) == 0xA000;
10✔
861
  }
862

863
  /**
864
   * Applies Unix file permissions stored in a ZIP entry to the extracted file.
865
   * <p>
866
   * Permissions are only applied on non-Windows systems because POSIX permission bits have no equivalent on Windows. If the entry carries no Unix attributes
867
   * (mode is {@code 0}), the call is a no-op.
868
   *
869
   * @param entry the source ZIP entry carrying the Unix mode.
870
   * @param target the extracted file whose permissions should be updated.
871
   */
872
  private void onFileCopiedFromZip(ZipArchiveEntry entry, Path target) {
873

874
    if (!this.context.getSystemInfo().isWindows()) {
5✔
875
      try {
876
        int unixMode = getZipUnixMode(entry);
3✔
877
        if (unixMode != 0) {
2✔
878
          Files.setPosixFilePermissions(target, PathPermissions.of(unixMode).toPosix());
6✔
879
        }
880
      } catch (Exception e) {
×
881
        LOG.error("Failed to transfer zip permissions for {}", target, e);
×
882
      }
1✔
883
    }
884
  }
1✔
885

886
  @Override
887
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
888

889
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
890
  }
1✔
891

892
  @Override
893
  public void extractJar(Path file, Path targetDir) {
894

895
    extractZip(file, targetDir);
4✔
896
  }
1✔
897

898
  /**
899
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
900
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
901
   */
902
  public static String generatePermissionString(int permissions) {
903

904
    // Ensure that only the last 9 bits are considered
905
    permissions &= 0b111111111;
4✔
906

907
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
908
    for (int i = 0; i < 9; i++) {
7✔
909
      int mask = 1 << i;
4✔
910
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
911
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
912
    }
913

914
    return permissionStringBuilder.toString();
3✔
915
  }
916

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

919
    LOG.info("Extracting TAR file {} to {}", file, targetDir);
5✔
920

921
    final List<PathLink> links = new ArrayList<>();
4✔
922
    try (InputStream is = Files.newInputStream(file);
5✔
923
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
924
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
925

926
      final Path root = targetDir.toAbsolutePath().normalize();
4✔
927

928
      ArchiveEntry entry = ais.getNextEntry();
3✔
929
      while (entry != null) {
2✔
930
        String entryName = entry.getName();
3✔
931
        Path entryPath = resolveRelativePathSecure(entryName, root);
5✔
932
        PathPermissions permissions = null;
2✔
933
        PathLinkType linkType = null;
2✔
934
        if (entry instanceof TarArchiveEntry tae) {
6!
935
          if (tae.isSymbolicLink()) {
3✔
936
            linkType = PathLinkType.SYMBOLIC_LINK;
3✔
937
          } else if (tae.isLink()) {
3!
938
            linkType = PathLinkType.HARD_LINK;
×
939
          }
940
          if (linkType == null) {
2✔
941
            permissions = PathPermissions.of(tae.getMode());
5✔
942
          } else {
943
            Path parent = entryPath.getParent();
3✔
944
            String sourcePathString = tae.getLinkName();
3✔
945
            Path absoluteSource = resolveRelativePathSecure(parent.resolve(sourcePathString).normalize(), root, sourcePathString);
9✔
946
            // symlink: preserve the raw target so chained links resolve once the bundle is fully extracted.
947
            // hardlink: pass the resolved absolute source - createHardLink requires an existing file.
948
            Path linkSource = linkType == PathLinkType.SYMBOLIC_LINK ? Path.of(sourcePathString) : absoluteSource;
9!
949
            links.add(new PathLink(linkSource, entryPath, linkType));
9✔
950
            mkdirs(parent);
3✔
951
          }
952
        }
953
        if (entry.isDirectory()) {
3✔
954
          mkdirs(entryPath);
4✔
955
        } else if (linkType == null) { // regular file
2✔
956
          mkdirs(entryPath.getParent());
4✔
957
          Files.copy(ais, entryPath, StandardCopyOption.REPLACE_EXISTING);
10✔
958
          // POSIX perms on non-Windows
959
          if (permissions != null) {
2!
960
            setFilePermissions(entryPath, permissions, false);
5✔
961
          }
962
        }
963
        pb.stepBy(Math.max(0L, entry.getSize()));
6✔
964
        entry = ais.getNextEntry();
3✔
965
      }
1✔
966
      // post process links
967
      for (PathLink link : links) {
10✔
968
        link(link);
4✔
969
      }
1✔
970
    } catch (Exception e) {
×
971
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
972
    }
1✔
973
  }
1✔
974

975
  private Path resolveRelativePathSecure(String entryName, Path root) {
976
    // Handle ZIP entries with absolute paths (e.g. "/bin/java") by converting them to relative paths
977
    if (entryName != null && entryName.startsWith("/")) {
6!
978
      entryName = entryName.substring(1);
×
979
    }
980

981
    Path entryPath = root.resolve(entryName).normalize();
5✔
982
    return resolveRelativePathSecure(entryPath, root, entryName);
6✔
983
  }
984

985
  private Path resolveRelativePathSecure(Path entryPath, Path root, String entryName) {
986

987
    if (!entryPath.startsWith(root)) {
4!
988
      throw new IllegalStateException("Preventing path traversal attack from " + entryName + " to " + entryPath + " leaving " + root);
×
989
    }
990
    return entryPath;
2✔
991
  }
992

993
  @Override
994
  public void extractDmg(Path file, Path targetDir) {
995

996
    LOG.info("Extracting DMG file {} to {}", file, targetDir);
×
997
    assert this.context.getSystemInfo().isMac();
×
998

999
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
1000
    mkdirs(mountPath);
×
1001
    ProcessContext pc = this.context.newProcess();
×
1002
    pc.executable("hdiutil");
×
1003
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
1004
    pc.run();
×
1005
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
1006
    if (appPath == null) {
×
1007
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
1008
    }
1009

1010
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
1011
    pc.addArgs("detach", "-force", mountPath);
×
1012
    pc.run();
×
1013
  }
×
1014

1015
  @Override
1016
  public void extractMsi(Path file, Path targetDir) {
1017

1018
    LOG.info("Extracting MSI file {} to {}", file, targetDir);
×
1019
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
1020
    // msiexec also creates a copy of the MSI
1021
    Path msiCopy = targetDir.resolve(file.getFileName());
×
1022
    delete(msiCopy);
×
1023
  }
×
1024

1025
  @Override
1026
  public void extractPkg(Path file, Path targetDir) {
1027

1028
    LOG.info("Extracting PKG file {} to {}", file, targetDir);
×
1029
    assert this.context.getSystemInfo().isMac();
×
1030
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
1031
    ProcessContext pc = this.context.newProcess();
×
1032
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
1033
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
1034
    extractPkgPayloadWithSystemTar(contentPath, targetDir);
×
1035
    delete(tmpDirPkg);
×
1036
  }
×
1037

1038
  private void extractPkgPayloadWithSystemTar(Path payload, Path targetDir) {
1039

1040
    mkdirs(targetDir);
×
1041
    ProcessContext pc = this.context.newProcess();
×
1042
    pc.executable("/usr/bin/tar");
×
1043
    pc.addArgs("-xf", payload, "-C", targetDir);
×
1044
    pc.run();
×
1045
  }
×
1046

1047
  @Override
1048
  public void compress(Path dir, OutputStream out, String path) {
1049

1050
    String extension = FilenameUtil.getExtension(path);
3✔
1051
    TarCompression tarCompression = TarCompression.of(extension);
3✔
1052
    if (tarCompression != null) {
2!
1053
      compressTar(dir, out, tarCompression);
6✔
1054
    } else if (extension.equals("zip")) {
×
1055
      compressZip(dir, out);
×
1056
    } else {
1057
      throw new IllegalArgumentException("Unsupported extension: " + extension);
×
1058
    }
1059
  }
1✔
1060

1061
  @Override
1062
  public void compressTar(Path dir, OutputStream out, TarCompression tarCompression) {
1063

1064
    switch (tarCompression) {
8!
1065
      case null -> compressTar(dir, out);
×
1066
      case NONE -> compressTar(dir, out);
×
1067
      case GZ -> compressTarGz(dir, out);
5✔
1068
      case BZIP2 -> compressTarBzip2(dir, out);
×
1069
      default -> throw new IllegalArgumentException("Unsupported tar compression: " + tarCompression);
×
1070
    }
1071
  }
1✔
1072

1073
  @Override
1074
  public void compressTarGz(Path dir, OutputStream out) {
1075

1076
    GzipParameters parameters = new GzipParameters();
4✔
1077
    parameters.setModificationTime(0);
3✔
1078
    try (GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(out, parameters)) {
6✔
1079
      compressTarOrThrow(dir, gzOut);
4✔
1080
    } catch (IOException e) {
×
1081
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.gz file.", e);
×
1082
    }
1✔
1083
  }
1✔
1084

1085
  @Override
1086
  public void compressTarBzip2(Path dir, OutputStream out) {
1087

1088
    try (BZip2CompressorOutputStream bzip2Out = new BZip2CompressorOutputStream(out)) {
×
1089
      compressTarOrThrow(dir, bzip2Out);
×
1090
    } catch (IOException e) {
×
1091
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.bz2 file.", e);
×
1092
    }
×
1093
  }
×
1094

1095
  @Override
1096
  public void compressTar(Path dir, OutputStream out) {
1097

1098
    try {
1099
      compressTarOrThrow(dir, out);
×
1100
    } catch (IOException e) {
×
1101
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar file.", e);
×
1102
    }
×
1103
  }
×
1104

1105
  private void compressTarOrThrow(Path dir, OutputStream out) throws IOException {
1106

1107
    try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) {
5✔
1108
      compressRecursive(dir, tarOut, "");
5✔
1109
      tarOut.finish();
2✔
1110
    }
1111
  }
1✔
1112

1113
  @Override
1114
  public void compressZip(Path dir, OutputStream out) {
1115

1116
    try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(out)) {
5✔
1117
      compressRecursive(dir, zipOut, "");
5✔
1118
      zipOut.finish();
2✔
1119
    } catch (IOException e) {
×
1120
      throw new IllegalStateException("Failed to compress directory " + dir + " to zip file.", e);
×
1121
    }
1✔
1122
  }
1✔
1123

1124
  private <E extends ArchiveEntry> void compressRecursive(Path path, ArchiveOutputStream<E> out, String relativePath) {
1125

1126
    try (Stream<Path> childStream = Files.list(path).sorted()) {
4✔
1127
      Iterator<Path> iterator = childStream.iterator();
3✔
1128
      while (iterator.hasNext()) {
3✔
1129
        Path child = iterator.next();
4✔
1130
        String relativeChildPath = relativePath + "/" + child.getFileName().toString();
6✔
1131
        boolean isDirectory = Files.isDirectory(child);
5✔
1132
        E archiveEntry = out.createArchiveEntry(child, relativeChildPath);
7✔
1133
        FileTime none = FileTime.fromMillis(0);
3✔
1134
        if (archiveEntry instanceof TarArchiveEntry tarEntry) {
6✔
1135
          tarEntry.setCreationTime(none);
3✔
1136
          tarEntry.setModTime(none);
3✔
1137
          tarEntry.setLastAccessTime(none);
3✔
1138
          tarEntry.setLastModifiedTime(none);
3✔
1139
          tarEntry.setUserId(0);
3✔
1140
          tarEntry.setUserName("user");
3✔
1141
          tarEntry.setGroupId(0);
3✔
1142
          tarEntry.setGroupName("group");
3✔
1143
          PathPermissions filePermissions = getFilePermissions(child);
4✔
1144
          tarEntry.setMode(filePermissions.toMode());
4✔
1145
        } else if (archiveEntry instanceof ZipArchiveEntry zipEntry) {
7!
1146
          zipEntry.setCreationTime(none);
4✔
1147
          zipEntry.setLastAccessTime(none);
4✔
1148
          zipEntry.setLastModifiedTime(none);
4✔
1149
          zipEntry.setTime(none);
3✔
1150
        }
1151
        out.putArchiveEntry(archiveEntry);
3✔
1152
        if (!isDirectory) {
2✔
1153
          try (InputStream in = Files.newInputStream(child)) {
5✔
1154
            IOUtils.copy(in, out);
4✔
1155
          }
1156
        }
1157
        out.closeArchiveEntry();
2✔
1158
        if (isDirectory) {
2✔
1159
          compressRecursive(child, out, relativeChildPath);
5✔
1160
        }
1161
      }
1✔
1162
    } catch (IOException e) {
×
1163
      throw new IllegalStateException("Failed to compress " + path, e);
×
1164
    }
1✔
1165
  }
1✔
1166

1167
  @Override
1168
  public void delete(Path path) {
1169

1170
    if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
1171
      LOG.trace("Deleting {} skipped as the path does not exist.", path);
4✔
1172
      return;
1✔
1173
    }
1174
    LOG.debug("Deleting {} ...", path);
4✔
1175
    try {
1176
      if (isLink(path)) {
4✔
1177
        deletePath(path);
4✔
1178
      } else {
1179
        deleteRecursive(path);
3✔
1180
      }
1181
    } catch (IOException e) {
×
1182
      throw new IllegalStateException("Failed to delete " + path, e);
×
1183
    }
1✔
1184
  }
1✔
1185

1186
  private void deleteRecursive(Path path) throws IOException {
1187

1188
    if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
1189
      LOG.trace("Deleting link {} ...", path);
4✔
1190
      Files.delete(path);
2✔
1191
      return;
1✔
1192
    }
1193
    if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
1194
      try (Stream<Path> childStream = Files.list(path)) {
3✔
1195
        Iterator<Path> iterator = childStream.iterator();
3✔
1196
        while (iterator.hasNext()) {
3✔
1197
          Path child = iterator.next();
4✔
1198
          deleteRecursive(child);
3✔
1199
        }
1✔
1200
      }
1201
    }
1202
    deletePath(path);
3✔
1203
  }
1✔
1204

1205
  private boolean isLink(Path path) {
1206

1207
    return Files.isSymbolicLink(path) || isJunction(path);
11!
1208
  }
1209

1210
  private void deletePath(Path path) throws IOException {
1211

1212
    LOG.trace("Deleting {} ...", path);
4✔
1213
    boolean isSetWritable = setWritable(path, true);
5✔
1214
    if (!isSetWritable) {
2✔
1215
      LOG.debug("Couldn't give write access to file: {}", path);
4✔
1216
    }
1217
    Files.delete(path);
2✔
1218
  }
1✔
1219

1220
  @Override
1221
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
1222

1223
    try {
1224
      if (!Files.isDirectory(dir)) {
5!
1225
        return null;
×
1226
      }
1227
      return findFirstRecursive(dir, filter, recursive);
6✔
1228
    } catch (IOException e) {
×
1229
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
1230
    }
1231
  }
1232

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

1235
    List<Path> folders = null;
2✔
1236
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1237
      Iterator<Path> iterator = childStream.iterator();
3✔
1238
      while (iterator.hasNext()) {
3✔
1239
        Path child = iterator.next();
4✔
1240
        if (filter.test(child)) {
4✔
1241
          return child;
4✔
1242
        } else if (recursive && Files.isDirectory(child)) {
2!
1243
          if (folders == null) {
×
1244
            folders = new ArrayList<>();
×
1245
          }
1246
          folders.add(child);
×
1247
        }
1248
      }
1✔
1249
    }
4!
1250
    if (folders != null) {
2!
1251
      for (Path child : folders) {
×
1252
        Path match = findFirstRecursive(child, filter, recursive);
×
1253
        if (match != null) {
×
1254
          return match;
×
1255
        }
1256
      }
×
1257
    }
1258
    return null;
2✔
1259
  }
1260

1261
  @Override
1262
  public Path findAncestor(Path path, Path baseDir, int subfolderCount) {
1263

1264
    if ((path == null) || (baseDir == null)) {
4!
1265
      LOG.debug("Path should not be null for findAncestor.");
3✔
1266
      return null;
2✔
1267
    }
1268
    if (subfolderCount <= 0) {
2!
1269
      throw new IllegalArgumentException("Subfolder count: " + subfolderCount);
×
1270
    }
1271
    // 1. option relativize
1272
    // 2. recursive getParent
1273
    // 3. loop getParent???
1274
    // 4. getName + getNameCount
1275
    path = path.toAbsolutePath().normalize();
4✔
1276
    baseDir = baseDir.toAbsolutePath().normalize();
4✔
1277
    int directoryNameCount = path.getNameCount();
3✔
1278
    int baseDirNameCount = baseDir.getNameCount();
3✔
1279
    int delta = directoryNameCount - baseDirNameCount - subfolderCount;
6✔
1280
    if (delta < 0) {
2!
1281
      return null;
×
1282
    }
1283
    // ensure directory is a sub-folder of baseDir
1284
    for (int i = 0; i < baseDirNameCount; i++) {
7✔
1285
      if (!path.getName(i).toString().equals(baseDir.getName(i).toString())) {
10✔
1286
        return null;
2✔
1287
      }
1288
    }
1289
    Path result = path;
2✔
1290
    while (delta > 0) {
2✔
1291
      result = result.getParent();
3✔
1292
      delta--;
2✔
1293
    }
1294
    return result;
2✔
1295
  }
1296

1297
  @Override
1298
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
1299

1300
    if (!Files.isDirectory(dir)) {
5✔
1301
      return List.of();
2✔
1302
    }
1303
    List<Path> children = new ArrayList<>();
4✔
1304
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1305
      Iterator<Path> iterator = childStream.iterator();
3✔
1306
      while (iterator.hasNext()) {
3✔
1307
        Path child = iterator.next();
4✔
1308
        Path filteredChild = filter.apply(child);
5✔
1309
        if (filteredChild != null) {
2✔
1310
          if (filteredChild == child) {
3!
1311
            LOG.trace("Accepted file {}", child);
5✔
1312
          } else {
1313
            LOG.trace("Accepted file {} and mapped to {}", child, filteredChild);
×
1314
          }
1315
          children.add(filteredChild);
5✔
1316
        } else {
1317
          LOG.trace("Ignoring file {} according to filter", child);
4✔
1318
        }
1319
      }
1✔
1320
    } catch (IOException e) {
×
1321
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
1322
    }
1✔
1323
    return children;
2✔
1324
  }
1325

1326
  @Override
1327
  public boolean isEmptyDir(Path dir) {
1328

1329
    return listChildren(dir, f -> true).isEmpty();
6✔
1330
  }
1331

1332
  @Override
1333
  public boolean isNonEmptyFile(Path file) {
1334

1335
    if (Files.isRegularFile(file)) {
5✔
1336
      return (getFileSize(file) > 0);
10✔
1337
    }
1338
    return false;
2✔
1339
  }
1340

1341
  private long getFileSize(Path file) {
1342

1343
    try {
1344
      return Files.size(file);
3✔
1345
    } catch (IOException e) {
×
1346
      LOG.warn("Failed to determine size of file {}: {}", file, e.toString(), e);
×
1347
      return 0;
×
1348
    }
1349
  }
1350

1351

1352
  @Override
1353
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
1354

1355
    for (Path dir : searchDirs) {
10!
1356
      Path filePath = dir.resolve(fileName);
4✔
1357
      try {
1358
        if (Files.exists(filePath)) {
5✔
1359
          return filePath;
2✔
1360
        }
1361
      } catch (Exception e) {
×
1362
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
1363
      }
1✔
1364
    }
1✔
1365
    return null;
×
1366
  }
1367

1368
  @Override
1369
  public boolean setWritable(Path file, boolean writable) {
1370

1371
    if (!Files.exists(file)) {
5✔
1372
      return false;
2✔
1373
    }
1374
    try {
1375
      // POSIX
1376
      PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class);
7✔
1377
      if (posix != null) {
2!
1378
        Set<PosixFilePermission> permissions = new HashSet<>(posix.readAttributes().permissions());
7✔
1379
        boolean changed;
1380
        if (writable) {
2!
1381
          changed = permissions.add(PosixFilePermission.OWNER_WRITE);
5✔
1382
        } else {
1383
          changed = permissions.remove(PosixFilePermission.OWNER_WRITE);
×
1384
        }
1385
        if (changed) {
2!
1386
          posix.setPermissions(permissions);
×
1387
        }
1388
        return true;
2✔
1389
      }
1390

1391
      // Windows
1392
      DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class);
×
1393
      if (dos != null) {
×
1394
        dos.setReadOnly(!writable);
×
1395
        return true;
×
1396
      }
1397

1398
      LOG.debug("Failed to set writing permission for file {}", file);
×
1399
      return false;
×
1400

1401
    } catch (IOException e) {
×
1402
      LOG.debug("Error occurred when trying to set writing permission for file {}: {}", file, e.toString(), e);
×
1403
      return false;
×
1404
    }
1405
  }
1406

1407
  @Override
1408
  public void makeExecutable(Path path, boolean confirm) {
1409

1410
    if (Files.exists(path)) {
5✔
1411
      if (skipPermissionsIfWindows(path)) {
4!
1412
        return;
×
1413
      }
1414
      PathPermissions existingPermissions = getFilePermissions(path);
4✔
1415
      PathPermissions executablePermissions = existingPermissions.makeExecutable();
3✔
1416
      boolean update = (executablePermissions != existingPermissions);
7✔
1417
      if (update) {
2✔
1418
        if (confirm) {
2!
1419
          boolean yesContinue = this.context.question(
×
1420
              "We want to execute {} but this command seems to lack executable permissions!\n"
1421
                  + "Most probably the tool vendor did forget to add x-flags in the binary release package.\n"
1422
                  + "Before running the command, we suggest to set executable permissions to the file:\n"
1423
                  + "{}\n"
1424
                  + "For security reasons we ask for your confirmation so please check this request.\n"
1425
                  + "Changing permissions from {} to {}.\n"
1426
                  + "Do you confirm to make the command executable before running it?", path.getFileName(), path, existingPermissions, executablePermissions);
×
1427
          if (!yesContinue) {
×
1428
            return;
×
1429
          }
1430
        }
1431
        setFilePermissions(path, executablePermissions, false);
6✔
1432
      } else {
1433
        LOG.trace("Executable flags already present so no need to set them for file {}", path);
4✔
1434
      }
1435
    } else {
1✔
1436
      LOG.warn("Cannot set executable flag on file that does not exist: {}", path);
4✔
1437
    }
1438
  }
1✔
1439

1440
  @Override
1441
  public PathPermissions getFilePermissions(Path path) {
1442

1443
    PathPermissions pathPermissions;
1444
    String info = "";
2✔
1445
    if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1446
      info = "mocked-";
×
1447
      if (Files.isDirectory(path)) {
×
1448
        pathPermissions = PathPermissions.MODE_RWX_RX_RX;
×
1449
      } else {
1450
        Path parent = path.getParent();
×
1451
        if ((parent != null) && (parent.getFileName().toString().equals("bin"))) {
×
1452
          pathPermissions = PathPermissions.MODE_RWX_RX_RX;
×
1453
        } else {
1454
          pathPermissions = PathPermissions.MODE_RW_R_R;
×
1455
        }
1456
      }
×
1457
    } else {
1458
      Set<PosixFilePermission> permissions;
1459
      try {
1460
        // Read the current file permissions
1461
        permissions = Files.getPosixFilePermissions(path);
5✔
1462
      } catch (IOException e) {
×
1463
        throw new RuntimeException("Failed to get permissions for " + path, e);
×
1464
      }
1✔
1465
      pathPermissions = PathPermissions.of(permissions);
3✔
1466
    }
1467
    LOG.trace("Read {}permissions of {} as {}.", info, path, pathPermissions);
17✔
1468
    return pathPermissions;
2✔
1469
  }
1470

1471
  @Override
1472
  public void setFilePermissions(Path path, PathPermissions permissions, boolean logErrorAndContinue) {
1473

1474
    if (skipPermissionsIfWindows(path)) {
4!
1475
      return;
×
1476
    }
1477
    try {
1478
      LOG.debug("Setting permissions for {} to {}", path, permissions);
5✔
1479
      // Set the new permissions
1480
      Files.setPosixFilePermissions(path, permissions.toPosix());
5✔
1481
    } catch (IOException e) {
×
1482
      String message = "Failed to set permissions to " + permissions + " for path " + path;
×
1483
      if (logErrorAndContinue) {
×
1484
        LOG.warn(message, e);
×
1485
      } else {
1486
        throw new RuntimeException(message, e);
×
1487
      }
1488
    }
1✔
1489
  }
1✔
1490

1491
  private boolean skipPermissionsIfWindows(Path path) {
1492

1493
    if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1494
      LOG.trace("Windows does not have file permissions hence omitting for {}", path);
×
1495
      return true;
×
1496
    }
1497
    return false;
2✔
1498
  }
1499

1500
  @Override
1501
  public void touch(Path file) {
1502

1503
    if (Files.exists(file)) {
5✔
1504
      try {
1505
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
1506
      } catch (IOException e) {
×
1507
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
1508
      }
1✔
1509
    } else {
1510
      try {
1511
        Files.createFile(file);
5✔
1512
      } catch (IOException e) {
1✔
1513
        throw new IllegalStateException("Could not create empty file " + file, e);
8✔
1514
      }
1✔
1515
    }
1516
  }
1✔
1517

1518
  @Override
1519
  public String readFileContent(Path file) {
1520

1521
    LOG.trace("Reading content of file from {}", file);
4✔
1522
    if (!Files.exists((file))) {
5✔
1523
      LOG.debug("File {} does not exist", file);
4✔
1524
      return null;
2✔
1525
    }
1526
    try {
1527
      String content = Files.readString(file);
3✔
1528
      LOG.trace("Completed reading {} character(s) from file {}", content.length(), file);
7✔
1529
      return content;
2✔
1530
    } catch (IOException e) {
×
1531
      throw new IllegalStateException("Failed to read file " + file, e);
×
1532
    }
1533
  }
1534

1535
  @Override
1536
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1537

1538
    if (createParentDir) {
2✔
1539
      mkdirs(file.getParent());
4✔
1540
    }
1541
    if (content == null) {
2!
1542
      content = "";
×
1543
    }
1544
    LOG.trace("Writing content with {} character(s) to file {}", content.length(), file);
7✔
1545
    if (Files.exists(file)) {
5✔
1546
      LOG.info("Overriding content of file {}", file);
4✔
1547
    }
1548
    try {
1549
      Files.writeString(file, content);
6✔
1550
      LOG.trace("Wrote content to file {}", file);
4✔
1551
    } catch (IOException e) {
×
1552
      throw new RuntimeException("Failed to write file " + file, e);
×
1553
    }
1✔
1554
  }
1✔
1555

1556
  @Override
1557
  public List<String> readFileLines(Path file) {
1558

1559
    LOG.trace("Reading lines of file from {}", file);
4✔
1560
    if (!Files.exists(file)) {
5✔
1561
      LOG.warn("File {} does not exist", file);
4✔
1562
      return null;
2✔
1563
    }
1564
    try {
1565
      List<String> content = Files.readAllLines(file);
3✔
1566
      LOG.trace("Completed reading {} lines from file {}", content.size(), file);
7✔
1567
      return content;
2✔
1568
    } catch (IOException e) {
×
1569
      throw new IllegalStateException("Failed to read file " + file, e);
×
1570
    }
1571
  }
1572

1573
  @Override
1574
  public void writeFileLines(List<String> content, Path file, boolean createParentDir) {
1575

1576
    if (createParentDir) {
2!
1577
      mkdirs(file.getParent());
×
1578
    }
1579
    if (content == null) {
2!
1580
      content = List.of();
×
1581
    }
1582
    LOG.trace("Writing content with {} lines to file {}", content.size(), file);
7✔
1583
    if (Files.exists(file)) {
5✔
1584
      LOG.debug("Overriding content of file {}", file);
4✔
1585
    }
1586
    try {
1587
      Files.write(file, content);
6✔
1588
      LOG.trace("Wrote lines to file {}", file);
4✔
1589
    } catch (IOException e) {
×
1590
      throw new RuntimeException("Failed to write file " + file, e);
×
1591
    }
1✔
1592
  }
1✔
1593

1594
  @Override
1595
  public void readProperties(Path file, Properties properties) {
1596

1597
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1598
      properties.load(reader);
3✔
1599
      LOG.debug("Successfully loaded {} properties from {}", properties.size(), file);
7✔
1600
    } catch (IOException e) {
×
1601
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1602
    }
1✔
1603
  }
1✔
1604

1605
  @Override
1606
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1607

1608
    if (createParentDir) {
2✔
1609
      mkdirs(file.getParent());
4✔
1610
    }
1611
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1612
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1613
      LOG.debug("Successfully saved {} properties to {}", properties.size(), file);
7✔
1614
    } catch (IOException e) {
×
1615
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1616
    }
1✔
1617
  }
1✔
1618

1619
  @Override
1620
  public void readIniFile(Path file, IniFile iniFile) {
1621

1622
    if (!Files.exists(file)) {
5✔
1623
      LOG.debug("INI file {} does not exist.", iniFile);
4✔
1624
      return;
1✔
1625
    }
1626
    List<String> iniLines = readFileLines(file);
4✔
1627
    IniSection currentIniSection = iniFile.getInitialSection();
3✔
1628
    for (String line : iniLines) {
10✔
1629
      if (line.trim().startsWith("[")) {
5✔
1630
        currentIniSection = iniFile.getOrCreateSection(line);
5✔
1631
      } else if (line.isBlank() || IniComment.COMMENT_SYMBOLS.contains(line.trim().charAt(0))) {
11✔
1632
        currentIniSection.addComment(line);
4✔
1633
      } else {
1634
        int index = line.indexOf('=');
4✔
1635
        if (index > 0) {
2!
1636
          currentIniSection.setPropertyLine(line);
3✔
1637
        }
1638
      }
1639
    }
1✔
1640
  }
1✔
1641

1642
  @Override
1643
  public void writeIniFile(IniFile iniFile, Path file, boolean createParentDir) {
1644

1645
    String iniString = iniFile.toString();
3✔
1646
    writeFileContent(iniString, file, createParentDir);
5✔
1647
  }
1✔
1648

1649
  @Override
1650
  public Duration getFileAge(Path path) {
1651

1652
    if (Files.exists(path)) {
5✔
1653
      try {
1654
        long currentTime = System.currentTimeMillis();
2✔
1655
        long fileModifiedTime = Files.getLastModifiedTime(path).toMillis();
6✔
1656
        return Duration.ofMillis(currentTime - fileModifiedTime);
5✔
1657
      } catch (IOException e) {
×
1658
        LOG.warn("Could not get modification-time of {}.", path, e);
×
1659
      }
×
1660
    } else {
1661
      LOG.debug("Path {} is missing - skipping modification-time and file age check.", path);
4✔
1662
    }
1663
    return null;
2✔
1664
  }
1665

1666
  @Override
1667
  public boolean isFileAgeRecent(Path path, Duration cacheDuration) {
1668

1669
    Duration age = getFileAge(path);
4✔
1670
    if (age == null) {
2✔
1671
      return false;
2✔
1672
    }
1673
    LOG.debug("The path {} was last updated {} ago and caching duration is {}.", path, age, cacheDuration);
17✔
1674
    return (age.toMillis() <= cacheDuration.toMillis());
10✔
1675
  }
1676
}
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