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

devonfw / IDEasy / 25723395287

12 May 2026 08:40AM UTC coverage: 70.637% (+0.01%) from 70.624%
25723395287

Pull #1924

github

web-flow
Merge 1ed83a25d into e6efbbdff
Pull Request #1924: #1884 keep symlinks while unzipping

4418 of 6916 branches covered (63.88%)

Branch coverage included in aggregate %.

11392 of 15466 relevant lines covered (73.66%)

3.12 hits per line

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

67.09
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.io.IOUtils;
54
import org.slf4j.Logger;
55
import org.slf4j.LoggerFactory;
56

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

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

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

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

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

84
  private final IdeContext context;
85

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

425
  /**
426
   * 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
427
   * {@link Path} that is neither a symbolic link nor a Windows junction.
428
   *
429
   * @param path the {@link Path} to delete.
430
   */
431
  private void deleteLinkIfExists(Path path) {
432

433
    boolean isJunction = isJunction(path); // since broken junctions are not detected by Files.exists()
4✔
434
    boolean isSymlink = Files.exists(path, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(path);
16!
435

436
    assert !(isSymlink && isJunction);
5!
437

438
    if (isJunction || isSymlink) {
4!
439
      LOG.info("Deleting previous " + (isJunction ? "junction" : "symlink") + " at " + path);
8!
440
      try {
441
        Files.delete(path);
2✔
442
      } catch (IOException e) {
×
443
        throw new IllegalStateException("Failed to delete link at " + path, e);
×
444
      }
1✔
445
    }
446
  }
1✔
447

448
  /**
449
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
450
   * is applied to {@code target}.
451
   *
452
   * @param link the {@link Path} the link should point to and that is to be adapted.
453
   * @param source the {@link Path} to the link. It is used to calculate the relative path to the {@code target} if {@code relative} is set to
454
   *     {@code true}.
455
   * @param relative the {@code relative} flag.
456
   * @return the adapted {@link Path}.
457
   * @see #symlink(Path, Path, boolean)
458
   */
459
  private Path adaptPath(Path source, Path link, boolean relative) {
460

461
    if (!source.isAbsolute()) {
3✔
462
      source = link.resolveSibling(source);
4✔
463
    }
464
    try {
465
      source = source.toRealPath(LinkOption.NOFOLLOW_LINKS); // to transform ../d1/../d2 to ../d2
9✔
466
    } catch (IOException e) {
×
467
      throw new RuntimeException("Failed to get real path of " + source, e);
×
468
    }
1✔
469
    if (relative) {
2✔
470
      source = link.getParent().relativize(source);
5✔
471
      // to make relative links like this work: dir/link -> dir
472
      source = (source.toString().isEmpty()) ? Path.of(".") : source;
11✔
473
    }
474
    return source;
2✔
475
  }
476

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

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

500
    String option = type.getMklinkOption();
×
501
    if (type == PathLinkType.SYMBOLIC_LINK) {
×
502
      boolean directoryTarget = Files.isDirectory(absoluteSource);
×
503
      option = directoryTarget ? "/j" : "/d";
×
504
    }
505

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

511
  private boolean runMklink(Path source, Path link, Path cwd, String option) {
512

513
    LOG.trace("Creating a Windows link with mklink {} at {} pointing to {}", option, link, source);
×
514
    ProcessContext pc = this.context.newProcess().executable("cmd").addArgs("/c", "mklink", option);
×
515
    if (cwd != null) {
×
516
      pc.directory(cwd);
×
517
    }
518
    ProcessResult result = pc.addArgs(link.toString(), source.toString()).run(ProcessMode.DEFAULT);
×
519
    try {
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 void link(Path source, Path link, boolean relative, PathLinkType type) {
530

531
    Path finalLink = link.toAbsolutePath().normalize();
4✔
532
    Path finalSource;
533
    try {
534
      finalSource = adaptPath(source, finalLink, relative);
6✔
535
    } catch (Exception e) {
×
536
      throw new IllegalStateException("Failed to adapt target (" + source + ") for link (" + finalLink + ") and relative (" + relative + ")", e);
×
537
    }
1✔
538
    Path absoluteSource = finalSource.isAbsolute() ? finalSource : finalLink.getParent().resolve(finalSource).normalize();
11✔
539
    String relativeOrAbsolute = relative ? "relative" : "absolute";
6✔
540
    LOG.debug("Creating {} {} at {} pointing to {}", relativeOrAbsolute, type, finalLink, finalSource);
21✔
541
    deleteLinkIfExists(finalLink);
3✔
542
    try {
543
      // Attention: JavaDoc and position of path arguments can be very confusing - see comment in #1736
544
      if (type == PathLinkType.SYMBOLIC_LINK) {
3!
545
        Files.createSymbolicLink(finalLink, finalSource);
7✔
546
      } else if (type == PathLinkType.HARD_LINK) {
×
547
        Files.createLink(finalLink, finalSource);
×
548
      } else {
549
        throw new IllegalStateException("" + type);
×
550
      }
551
    } catch (FileSystemException e) {
×
552
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
553
        LOG.info(
×
554
            "Due to lack of permissions, Microsoft's mklink with junction had to be used to create a Symlink. See\n"
555
                + "https://github.com/devonfw/IDEasy/blob/main/documentation/symlink.adoc for further details. Error was: "
556
                + e.getMessage());
×
557
        mklinkOnWindows(finalSource, absoluteSource, finalLink, type, relative);
×
558
      } else {
559
        throw new RuntimeException(e);
×
560
      }
561
    } catch (IOException e) {
×
562
      throw new IllegalStateException("Failed to create a " + relativeOrAbsolute + " " + type + " at " + finalLink + " pointing to " + source, e);
×
563
    }
1✔
564
  }
1✔
565

566
  @Override
567
  public Path getPathStart(Path path, int nameEnd) {
568

569
    int count = path.getNameCount();
3✔
570
    int delta = count - nameEnd - 1;
6✔
571
    if (delta < 0) {
2!
572
      throw new IllegalArgumentException("Cannot get start before segment " + nameEnd + " from path " + path + " that has only " + count + " segments.");
×
573
    }
574
    Path result = path;
2✔
575
    while (delta > 0) {
2✔
576
      result = result.getParent();
3✔
577
      delta--;
2✔
578
    }
579
    return result;
2✔
580
  }
581

582
  @Override
583
  public Path getPathEnd(Path path, int nameStart) {
584

585
    int count = path.getNameCount();
3✔
586
    if (nameStart > count) {
3!
587
      throw new IllegalArgumentException("Cannot get end after segment " + nameStart + " from path " + path + " that has only " + count + " segments.");
×
588
    } else if (nameStart == count) {
3!
589
      return Path.of(".");
×
590
    }
591
    Path result = path.getName(nameStart++);
5✔
592
    while (nameStart < count) {
3✔
593
      result = result.resolve(path.getName(nameStart++));
8✔
594
    }
595
    return result;
2✔
596
  }
597

598
  @Override
599
  public int getCommonNameCount(Path path1, Path path2) {
600

601
    if ((path1 == null) || (path2 == null) || !Objects.equals(path1.getRoot(), path2.getRoot())) {
10✔
602
      return -1;
2✔
603
    }
604
    int minNameCount = Math.min(path1.getNameCount(), path2.getNameCount());
6✔
605
    int i = 0;
2✔
606
    while (i < minNameCount) {
3!
607
      Path segment1 = path1.getName(i);
4✔
608
      Path segment2 = path2.getName(i);
4✔
609
      if (!segment1.equals(segment2)) {
4✔
610
        break;
1✔
611
      }
612
      i++;
1✔
613
    }
1✔
614
    return i;
2✔
615
  }
616

617
  @Override
618
  public Path toRealPath(Path path) {
619

620
    return toRealPath(path, true);
5✔
621
  }
622

623
  @Override
624
  public Path toCanonicalPath(Path path) {
625

626
    return toRealPath(path, false);
5✔
627
  }
628

629
  private Path toRealPath(Path path, boolean resolveLinks) {
630

631
    try {
632
      Path realPath;
633
      if (resolveLinks) {
2✔
634
        realPath = path.toRealPath();
6✔
635
      } else {
636
        realPath = path.toRealPath(LinkOption.NOFOLLOW_LINKS);
9✔
637
      }
638
      if (!realPath.equals(path)) {
4✔
639
        LOG.trace("Resolved path {} to {}", path, realPath);
5✔
640
      }
641
      return realPath;
2✔
642
    } catch (IOException e) {
×
643
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
644
    }
645
  }
646

647
  @Override
648
  public Path createTempDir(String name) {
649

650
    try {
651
      Path tmp = this.context.getTempPath();
4✔
652
      Path tempDir = tmp.resolve(name);
4✔
653
      int tries = 1;
2✔
654
      while (Files.exists(tempDir)) {
5!
655
        long id = System.nanoTime() & 0xFFFF;
×
656
        tempDir = tmp.resolve(name + "-" + id);
×
657
        tries++;
×
658
        if (tries > 200) {
×
659
          throw new IOException("Unable to create unique name!");
×
660
        }
661
      }
×
662
      return Files.createDirectory(tempDir);
5✔
663
    } catch (IOException e) {
×
664
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
665
    }
666
  }
667

668
  @Override
669
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
670

671
    if (Files.isDirectory(archiveFile)) {
5✔
672
      LOG.warn("Found directory for download at {} hence copying without extraction!", archiveFile);
4✔
673
      copy(archiveFile, targetDir, FileCopyMode.COPY_TREE_CONTENT);
5✔
674
      postExtractHook(postExtractHook, targetDir);
4✔
675
      return;
1✔
676
    } else if (!extract) {
2✔
677
      mkdirs(targetDir);
3✔
678
      move(archiveFile, targetDir.resolve(archiveFile.getFileName()));
9✔
679
      return;
1✔
680
    }
681
    Path tmpDir = createTempDir("extract-" + archiveFile.getFileName());
7✔
682
    LOG.trace("Trying to extract the file {} to {} and move it to {}.", archiveFile, tmpDir, targetDir);
17✔
683
    String filename = archiveFile.getFileName().toString();
4✔
684
    TarCompression tarCompression = TarCompression.of(filename);
3✔
685
    if (tarCompression != null) {
2✔
686
      extractTar(archiveFile, tmpDir, tarCompression);
6✔
687
    } else {
688
      String extension = FilenameUtil.getExtension(filename);
3✔
689
      if (extension == null) {
2!
690
        throw new IllegalStateException("Unknown archive format without extension - can not extract " + archiveFile);
×
691
      } else {
692
        LOG.trace("Determined file extension {}", extension);
4✔
693
      }
694
      switch (extension) {
8!
695
        case "zip" -> extractZip(archiveFile, tmpDir);
×
696
        case "jar" -> extractJar(archiveFile, tmpDir);
5✔
697
        case "dmg" -> extractDmg(archiveFile, tmpDir);
×
698
        case "msi" -> extractMsi(archiveFile, tmpDir);
×
699
        case "pkg" -> extractPkg(archiveFile, tmpDir);
×
700
        default -> throw new IllegalStateException("Unknown archive format " + extension + ". Can not extract " + archiveFile);
×
701
      }
702
    }
703
    Path properInstallDir = getProperInstallationSubDirOf(tmpDir, archiveFile);
5✔
704
    postExtractHook(postExtractHook, properInstallDir);
4✔
705
    move(properInstallDir, targetDir);
6✔
706
    delete(tmpDir);
3✔
707
  }
1✔
708

709
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
710

711
    if (postExtractHook != null) {
2✔
712
      postExtractHook.accept(properInstallDir);
3✔
713
    }
714
  }
1✔
715

716
  /**
717
   * @param path the {@link Path} to start the recursive search from.
718
   * @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
719
   *     item in their respective directory and {@code s} is not named "bin".
720
   */
721
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
722

723
    try (Stream<Path> stream = Files.list(path)) {
3✔
724
      Path[] subFiles = stream.toArray(Path[]::new);
8✔
725
      if (subFiles.length == 0) {
3!
726
        throw new CliException("The downloaded package " + archiveFile + " seems to be empty as you can check in the extracted folder " + path);
×
727
      } else if (subFiles.length == 1) {
4✔
728
        String filename = subFiles[0].getFileName().toString();
6✔
729
        if (!filename.equals(IdeContext.FOLDER_BIN) && !filename.equals(IdeContext.FOLDER_CONTENTS) && !filename.endsWith(".app") && Files.isDirectory(
19!
730
            subFiles[0])) {
731
          return getProperInstallationSubDirOf(subFiles[0], archiveFile);
9✔
732
        }
733
      }
734
      return path;
4✔
735
    } catch (IOException e) {
4!
736
      throw new IllegalStateException("Failed to get sub-files of " + path);
×
737
    }
738
  }
739

740
  @Override
741
  public void extractZip(Path file, Path targetDir) {
742

743
    LOG.info("Extracting ZIP file {} to {}", file, targetDir);
5✔
744
    extractZipWithJava(file, targetDir);
4✔
745
  }
1✔
746

747
  /**
748
   * Extracts a ZIP archive to the given target directory using Java (commons-compress {@link ZipFile}).
749
   * <p>
750
   * Symlinks are handled in a two-phase approach: all regular files and directories are written first, and symlinks are
751
   * collected and created afterwards. This ensures every symlink target already exists on disk before the link is made.
752
   * <p>
753
   * {@link ZipFile} is used instead of {@link org.apache.commons.compress.archivers.zip.ZipArchiveInputStream} because
754
   * Unix file attributes (needed for symlink detection and permission restoration) are stored in the ZIP central
755
   * directory at the end of the file. A sequential stream only sees the local file headers, where external attributes
756
   * are always zero. {@link ZipFile} reads the central directory via random access, so attributes are always correct.
757
   *
758
   * @param file the ZIP archive to extract.
759
   * @param targetDir the directory to extract into.
760
   */
761
  private void extractZipWithJava(Path file, Path targetDir) {
762

763
    final List<PathLink> links = new ArrayList<>();
4✔
764
    try (ZipFile zipFile = ZipFile.builder().setPath(file).get();
6✔
765
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
766

767
      final Path root = targetDir.toAbsolutePath().normalize();
4✔
768
      Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
3✔
769

770
      while (entries.hasMoreElements()) {
3✔
771
        ZipArchiveEntry entry = entries.nextElement();
4✔
772
        String entryName = entry.getName();
3✔
773
        Path entryPath = resolveRelativePathSecure(entryName, root);
5✔
774

775
        if (isZipSymlink(entry)) {
3✔
776
          try (InputStream entryStream = zipFile.getInputStream(entry)) {
5✔
777
            // For symlink entries the file content IS the link target path (Unix zip convention).
778
            String linkTarget = IOUtils.toString(entryStream, StandardCharsets.UTF_8);
4✔
779
            Path parent = entryPath.getParent();
3✔
780
            Path source = parent.resolve(linkTarget).normalize();
5✔
781
            resolveRelativePathSecure(source, root, linkTarget);
6✔
782
            links.add(new PathLink(source, entryPath, PathLinkType.SYMBOLIC_LINK));
9✔
783
            mkdirs(parent);
3✔
784
          }
785
        } else if (entry.isDirectory()) {
3✔
786
          mkdirs(entryPath);
4✔
787
        } else {
788
          mkdirs(entryPath.getParent());
4✔
789
          try (InputStream entryStream = zipFile.getInputStream(entry)) {
4✔
790
            Files.copy(entryStream, entryPath, StandardCopyOption.REPLACE_EXISTING);
10✔
791
          }
792
          onFileCopiedFromZip(entry, entryPath);
4✔
793
        }
794
        pb.stepBy(Math.max(0L, entry.getSize()));
6✔
795
      }
1✔
796

797
      // Phase 2: create all symlinks now that their targets are guaranteed to exist.
798
      for (PathLink link : links) {
10✔
799
        link(link);
3✔
800
      }
1✔
801
    } catch (IOException e) {
×
802
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
803
    }
1✔
804
  }
1✔
805

806
  /**
807
   * Returns the Unix file mode stored in the external file attributes of the given ZIP entry.
808
   * <p>
809
   * The ZIP specification stores platform-specific metadata in a 32-bit external-attributes field. When the archive was
810
   * created on a Unix system the layout is:
811
   * <ul>
812
   *   <li>bits 31–16 (upper 16 bits): Unix file mode (same bit layout as {@code stat.st_mode})</li>
813
   *   <li>bits 15–0 (lower 16 bits): MS-DOS attributes (read-only, hidden, …)</li>
814
   * </ul>
815
   * Shifting right by 16 and masking with {@code 0xFFFF} isolates the Unix mode.
816
   *
817
   * @param entry the ZIP entry to read.
818
   * @return the Unix file mode, or {@code 0} if no Unix attributes are present.
819
   */
820
  private static int getZipUnixMode(ZipArchiveEntry entry) {
821

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

827
  /**
828
   * Returns {@code true} if the given ZIP entry represents a symbolic link.
829
   * <p>
830
   * Unix file modes encode the file type in the top 4 bits (bits 15–12) of the mode word. The possible file-type
831
   * values are defined in {@code <sys/stat.h>}:
832
   * <ul>
833
   *   <li>{@code 0x8000} – regular file ({@code S_IFREG})</li>
834
   *   <li>{@code 0x4000} – directory ({@code S_IFDIR})</li>
835
   *   <li>{@code 0xA000} – symbolic link ({@code S_IFLNK})</li>
836
   * </ul>
837
   * Masking with {@code 0xF000} isolates those top 4 bits and comparing against {@code 0xA000} identifies symlinks.
838
   *
839
   * @param entry the ZIP entry to test.
840
   * @return {@code true} if the entry is a symbolic link, {@code false} otherwise.
841
   */
842
  private static boolean isZipSymlink(ZipArchiveEntry entry) {
843

844
    // 0xF000 masks the file-type nibble; 0xA000 is the Unix S_IFLNK constant.
845
    return (getZipUnixMode(entry) & 0xF000) == 0xA000;
10✔
846
  }
847

848
  /**
849
   * Applies Unix file permissions stored in a ZIP entry to the extracted file.
850
   * <p>
851
   * Permissions are only applied on non-Windows systems because POSIX permission bits have no equivalent on Windows.
852
   * If the entry carries no Unix attributes (mode is {@code 0}), the call is a no-op.
853
   *
854
   * @param entry the source ZIP entry carrying the Unix mode.
855
   * @param target the extracted file whose permissions should be updated.
856
   */
857
  private void onFileCopiedFromZip(ZipArchiveEntry entry, Path target) {
858

859
    if (!this.context.getSystemInfo().isWindows()) {
5✔
860
      try {
861
        int unixMode = getZipUnixMode(entry);
3✔
862
        if (unixMode != 0) {
2✔
863
          Files.setPosixFilePermissions(target, PathPermissions.of(unixMode).toPosix());
6✔
864
        }
865
      } catch (Exception e) {
×
866
        LOG.error("Failed to transfer zip permissions for {}", target, e);
×
867
      }
1✔
868
    }
869
  }
1✔
870

871
  @Override
872
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
873

874
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
875
  }
1✔
876

877
  @Override
878
  public void extractJar(Path file, Path targetDir) {
879

880
    extractZip(file, targetDir);
4✔
881
  }
1✔
882

883
  /**
884
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
885
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
886
   */
887
  public static String generatePermissionString(int permissions) {
888

889
    // Ensure that only the last 9 bits are considered
890
    permissions &= 0b111111111;
4✔
891

892
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
893
    for (int i = 0; i < 9; i++) {
7✔
894
      int mask = 1 << i;
4✔
895
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
896
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
897
    }
898

899
    return permissionStringBuilder.toString();
3✔
900
  }
901

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

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

906
    final List<PathLink> links = new ArrayList<>();
4✔
907
    try (InputStream is = Files.newInputStream(file);
5✔
908
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
909
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
910

911
      final Path root = targetDir.toAbsolutePath().normalize();
4✔
912

913
      ArchiveEntry entry = ais.getNextEntry();
3✔
914
      while (entry != null) {
2✔
915
        String entryName = entry.getName();
3✔
916
        Path entryPath = resolveRelativePathSecure(entryName, root);
5✔
917
        PathPermissions permissions = null;
2✔
918
        PathLinkType linkType = null;
2✔
919
        if (entry instanceof TarArchiveEntry tae) {
6!
920
          if (tae.isSymbolicLink()) {
3✔
921
            linkType = PathLinkType.SYMBOLIC_LINK;
3✔
922
          } else if (tae.isLink()) {
3!
923
            linkType = PathLinkType.HARD_LINK;
×
924
          }
925
          if (linkType == null) {
2✔
926
            permissions = PathPermissions.of(tae.getMode());
5✔
927
          } else {
928
            Path parent = entryPath.getParent();
3✔
929
            String sourcePathString = tae.getLinkName();
3✔
930
            Path source = parent.resolve(sourcePathString).normalize();
5✔
931
            source = resolveRelativePathSecure(source, root, sourcePathString);
6✔
932
            links.add(new PathLink(source, entryPath, linkType));
9✔
933
            mkdirs(parent);
3✔
934
          }
935
        }
936
        if (entry.isDirectory()) {
3✔
937
          mkdirs(entryPath);
4✔
938
        } else if (linkType == null) { // regular file
2✔
939
          mkdirs(entryPath.getParent());
4✔
940
          Files.copy(ais, entryPath, StandardCopyOption.REPLACE_EXISTING);
10✔
941
          // POSIX perms on non-Windows
942
          if (permissions != null) {
2!
943
            setFilePermissions(entryPath, permissions, false);
5✔
944
          }
945
        }
946
        pb.stepBy(Math.max(0L, entry.getSize()));
6✔
947
        entry = ais.getNextEntry();
3✔
948
      }
1✔
949
      // post process links
950
      for (PathLink link : links) {
10✔
951
        link(link);
3✔
952
      }
1✔
953
    } catch (Exception e) {
×
954
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
955
    }
1✔
956
  }
1✔
957

958
  private Path resolveRelativePathSecure(String entryName, Path root) {
959

960
    Path entryPath = root.resolve(entryName).normalize();
5✔
961
    return resolveRelativePathSecure(entryPath, root, entryName);
6✔
962
  }
963

964
  private Path resolveRelativePathSecure(Path entryPath, Path root, String entryName) {
965

966
    if (!entryPath.startsWith(root)) {
4!
967
      throw new IllegalStateException("Preventing path traversal attack from " + entryName + " to " + entryPath + " leaving " + root);
×
968
    }
969
    return entryPath;
2✔
970
  }
971

972
  @Override
973
  public void extractDmg(Path file, Path targetDir) {
974

975
    LOG.info("Extracting DMG file {} to {}", file, targetDir);
×
976
    assert this.context.getSystemInfo().isMac();
×
977

978
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
979
    mkdirs(mountPath);
×
980
    ProcessContext pc = this.context.newProcess();
×
981
    pc.executable("hdiutil");
×
982
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
983
    pc.run();
×
984
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
985
    if (appPath == null) {
×
986
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
987
    }
988

989
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
990
    pc.addArgs("detach", "-force", mountPath);
×
991
    pc.run();
×
992
  }
×
993

994
  @Override
995
  public void extractMsi(Path file, Path targetDir) {
996

997
    LOG.info("Extracting MSI file {} to {}", file, targetDir);
×
998
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
999
    // msiexec also creates a copy of the MSI
1000
    Path msiCopy = targetDir.resolve(file.getFileName());
×
1001
    delete(msiCopy);
×
1002
  }
×
1003

1004
  @Override
1005
  public void extractPkg(Path file, Path targetDir) {
1006

1007
    LOG.info("Extracting PKG file {} to {}", file, targetDir);
×
1008
    assert this.context.getSystemInfo().isMac();
×
1009
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
1010
    ProcessContext pc = this.context.newProcess();
×
1011
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
1012
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
1013
    extractPkgPayloadWithSystemTar(contentPath, targetDir);
×
1014
    delete(tmpDirPkg);
×
1015
  }
×
1016

1017
  private void extractPkgPayloadWithSystemTar(Path payload, Path targetDir) {
1018

1019
    mkdirs(targetDir);
×
1020
    ProcessContext pc = this.context.newProcess();
×
1021
    pc.executable("/usr/bin/tar");
×
1022
    pc.addArgs("-xf", payload, "-C", targetDir);
×
1023
    pc.run();
×
1024
  }
×
1025

1026
  @Override
1027
  public void compress(Path dir, OutputStream out, String path) {
1028

1029
    String extension = FilenameUtil.getExtension(path);
3✔
1030
    TarCompression tarCompression = TarCompression.of(extension);
3✔
1031
    if (tarCompression != null) {
2!
1032
      compressTar(dir, out, tarCompression);
6✔
1033
    } else if (extension.equals("zip")) {
×
1034
      compressZip(dir, out);
×
1035
    } else {
1036
      throw new IllegalArgumentException("Unsupported extension: " + extension);
×
1037
    }
1038
  }
1✔
1039

1040
  @Override
1041
  public void compressTar(Path dir, OutputStream out, TarCompression tarCompression) {
1042

1043
    switch (tarCompression) {
8!
1044
      case null -> compressTar(dir, out);
×
1045
      case NONE -> compressTar(dir, out);
×
1046
      case GZ -> compressTarGz(dir, out);
5✔
1047
      case BZIP2 -> compressTarBzip2(dir, out);
×
1048
      default -> throw new IllegalArgumentException("Unsupported tar compression: " + tarCompression);
×
1049
    }
1050
  }
1✔
1051

1052
  @Override
1053
  public void compressTarGz(Path dir, OutputStream out) {
1054

1055
    try (GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(out)) {
5✔
1056
      compressTarOrThrow(dir, gzOut);
4✔
1057
    } catch (IOException e) {
×
1058
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.gz file.", e);
×
1059
    }
1✔
1060
  }
1✔
1061

1062
  @Override
1063
  public void compressTarBzip2(Path dir, OutputStream out) {
1064

1065
    try (BZip2CompressorOutputStream bzip2Out = new BZip2CompressorOutputStream(out)) {
×
1066
      compressTarOrThrow(dir, bzip2Out);
×
1067
    } catch (IOException e) {
×
1068
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.bz2 file.", e);
×
1069
    }
×
1070
  }
×
1071

1072
  @Override
1073
  public void compressTar(Path dir, OutputStream out) {
1074

1075
    try {
1076
      compressTarOrThrow(dir, out);
×
1077
    } catch (IOException e) {
×
1078
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar file.", e);
×
1079
    }
×
1080
  }
×
1081

1082
  private void compressTarOrThrow(Path dir, OutputStream out) throws IOException {
1083

1084
    try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) {
5✔
1085
      compressRecursive(dir, tarOut, "");
5✔
1086
      tarOut.finish();
2✔
1087
    }
1088
  }
1✔
1089

1090
  @Override
1091
  public void compressZip(Path dir, OutputStream out) {
1092

1093
    try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(out)) {
×
1094
      compressRecursive(dir, zipOut, "");
×
1095
      zipOut.finish();
×
1096
    } catch (IOException e) {
×
1097
      throw new IllegalStateException("Failed to compress directory " + dir + " to zip file.", e);
×
1098
    }
×
1099
  }
×
1100

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

1103
    try (Stream<Path> childStream = Files.list(path)) {
3✔
1104
      Iterator<Path> iterator = childStream.iterator();
3✔
1105
      while (iterator.hasNext()) {
3✔
1106
        Path child = iterator.next();
4✔
1107
        String relativeChildPath = relativePath + "/" + child.getFileName().toString();
6✔
1108
        boolean isDirectory = Files.isDirectory(child);
5✔
1109
        E archiveEntry = out.createArchiveEntry(child, relativeChildPath);
7✔
1110
        if (archiveEntry instanceof TarArchiveEntry tarEntry) {
6!
1111
          FileTime none = FileTime.fromMillis(0);
3✔
1112
          tarEntry.setCreationTime(none);
3✔
1113
          tarEntry.setModTime(none);
3✔
1114
          tarEntry.setLastAccessTime(none);
3✔
1115
          tarEntry.setLastModifiedTime(none);
3✔
1116
          tarEntry.setUserId(0);
3✔
1117
          tarEntry.setUserName("user");
3✔
1118
          tarEntry.setGroupId(0);
3✔
1119
          tarEntry.setGroupName("group");
3✔
1120
          PathPermissions filePermissions = getFilePermissions(child);
4✔
1121
          tarEntry.setMode(filePermissions.toMode());
4✔
1122
        }
1123
        out.putArchiveEntry(archiveEntry);
3✔
1124
        if (!isDirectory) {
2✔
1125
          try (InputStream in = Files.newInputStream(child)) {
5✔
1126
            IOUtils.copy(in, out);
4✔
1127
          }
1128
        }
1129
        out.closeArchiveEntry();
2✔
1130
        if (isDirectory) {
2✔
1131
          compressRecursive(child, out, relativeChildPath);
5✔
1132
        }
1133
      }
1✔
1134
    } catch (IOException e) {
×
1135
      throw new IllegalStateException("Failed to compress " + path, e);
×
1136
    }
1✔
1137
  }
1✔
1138

1139
  @Override
1140
  public void delete(Path path) {
1141

1142
    if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
1143
      LOG.trace("Deleting {} skipped as the path does not exist.", path);
4✔
1144
      return;
1✔
1145
    }
1146
    LOG.debug("Deleting {} ...", path);
4✔
1147
    try {
1148
      if (isLink(path)) {
4✔
1149
        deletePath(path);
4✔
1150
      } else {
1151
        deleteRecursive(path);
3✔
1152
      }
1153
    } catch (IOException e) {
×
1154
      throw new IllegalStateException("Failed to delete " + path, e);
×
1155
    }
1✔
1156
  }
1✔
1157

1158
  private void deleteRecursive(Path path) throws IOException {
1159

1160
    if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
1161
      LOG.trace("Deleting link {} ...", path);
4✔
1162
      Files.delete(path);
2✔
1163
      return;
1✔
1164
    }
1165
    if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
1166
      try (Stream<Path> childStream = Files.list(path)) {
3✔
1167
        Iterator<Path> iterator = childStream.iterator();
3✔
1168
        while (iterator.hasNext()) {
3✔
1169
          Path child = iterator.next();
4✔
1170
          deleteRecursive(child);
3✔
1171
        }
1✔
1172
      }
1173
    }
1174
    deletePath(path);
3✔
1175
  }
1✔
1176

1177
  private boolean isLink(Path path) {
1178

1179
    return Files.isSymbolicLink(path) || isJunction(path);
11!
1180
  }
1181

1182
  private void deletePath(Path path) throws IOException {
1183

1184
    LOG.trace("Deleting {} ...", path);
4✔
1185
    boolean isSetWritable = setWritable(path, true);
5✔
1186
    if (!isSetWritable) {
2✔
1187
      LOG.debug("Couldn't give write access to file: {}", path);
4✔
1188
    }
1189
    Files.delete(path);
2✔
1190
  }
1✔
1191

1192
  @Override
1193
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
1194

1195
    try {
1196
      if (!Files.isDirectory(dir)) {
5!
1197
        return null;
×
1198
      }
1199
      return findFirstRecursive(dir, filter, recursive);
6✔
1200
    } catch (IOException e) {
×
1201
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
1202
    }
1203
  }
1204

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

1207
    List<Path> folders = null;
2✔
1208
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1209
      Iterator<Path> iterator = childStream.iterator();
3✔
1210
      while (iterator.hasNext()) {
3✔
1211
        Path child = iterator.next();
4✔
1212
        if (filter.test(child)) {
4✔
1213
          return child;
4✔
1214
        } else if (recursive && Files.isDirectory(child)) {
2!
1215
          if (folders == null) {
×
1216
            folders = new ArrayList<>();
×
1217
          }
1218
          folders.add(child);
×
1219
        }
1220
      }
1✔
1221
    }
4!
1222
    if (folders != null) {
2!
1223
      for (Path child : folders) {
×
1224
        Path match = findFirstRecursive(child, filter, recursive);
×
1225
        if (match != null) {
×
1226
          return match;
×
1227
        }
1228
      }
×
1229
    }
1230
    return null;
2✔
1231
  }
1232

1233
  @Override
1234
  public Path findAncestor(Path path, Path baseDir, int subfolderCount) {
1235

1236
    if ((path == null) || (baseDir == null)) {
4!
1237
      LOG.debug("Path should not be null for findAncestor.");
3✔
1238
      return null;
2✔
1239
    }
1240
    if (subfolderCount <= 0) {
2!
1241
      throw new IllegalArgumentException("Subfolder count: " + subfolderCount);
×
1242
    }
1243
    // 1. option relativize
1244
    // 2. recursive getParent
1245
    // 3. loop getParent???
1246
    // 4. getName + getNameCount
1247
    path = path.toAbsolutePath().normalize();
4✔
1248
    baseDir = baseDir.toAbsolutePath().normalize();
4✔
1249
    int directoryNameCount = path.getNameCount();
3✔
1250
    int baseDirNameCount = baseDir.getNameCount();
3✔
1251
    int delta = directoryNameCount - baseDirNameCount - subfolderCount;
6✔
1252
    if (delta < 0) {
2!
1253
      return null;
×
1254
    }
1255
    // ensure directory is a sub-folder of baseDir
1256
    for (int i = 0; i < baseDirNameCount; i++) {
7✔
1257
      if (!path.getName(i).toString().equals(baseDir.getName(i).toString())) {
10✔
1258
        return null;
2✔
1259
      }
1260
    }
1261
    Path result = path;
2✔
1262
    while (delta > 0) {
2✔
1263
      result = result.getParent();
3✔
1264
      delta--;
2✔
1265
    }
1266
    return result;
2✔
1267
  }
1268

1269
  @Override
1270
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
1271

1272
    if (!Files.isDirectory(dir)) {
5✔
1273
      return List.of();
2✔
1274
    }
1275
    List<Path> children = new ArrayList<>();
4✔
1276
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1277
      Iterator<Path> iterator = childStream.iterator();
3✔
1278
      while (iterator.hasNext()) {
3✔
1279
        Path child = iterator.next();
4✔
1280
        Path filteredChild = filter.apply(child);
5✔
1281
        if (filteredChild != null) {
2✔
1282
          if (filteredChild == child) {
3!
1283
            LOG.trace("Accepted file {}", child);
5✔
1284
          } else {
1285
            LOG.trace("Accepted file {} and mapped to {}", child, filteredChild);
×
1286
          }
1287
          children.add(filteredChild);
5✔
1288
        } else {
1289
          LOG.trace("Ignoring file {} according to filter", child);
4✔
1290
        }
1291
      }
1✔
1292
    } catch (IOException e) {
×
1293
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
1294
    }
1✔
1295
    return children;
2✔
1296
  }
1297

1298
  @Override
1299
  public boolean isEmptyDir(Path dir) {
1300

1301
    return listChildren(dir, f -> true).isEmpty();
6✔
1302
  }
1303

1304
  @Override
1305
  public boolean isNonEmptyFile(Path file) {
1306

1307
    if (Files.isRegularFile(file)) {
5✔
1308
      return (getFileSize(file) > 0);
10✔
1309
    }
1310
    return false;
2✔
1311
  }
1312

1313
  private long getFileSize(Path file) {
1314

1315
    try {
1316
      return Files.size(file);
3✔
1317
    } catch (IOException e) {
×
1318
      LOG.warn("Failed to determine size of file {}: {}", file, e.toString(), e);
×
1319
      return 0;
×
1320
    }
1321
  }
1322

1323

1324

1325
  @Override
1326
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
1327

1328
    for (Path dir : searchDirs) {
10!
1329
      Path filePath = dir.resolve(fileName);
4✔
1330
      try {
1331
        if (Files.exists(filePath)) {
5✔
1332
          return filePath;
2✔
1333
        }
1334
      } catch (Exception e) {
×
1335
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
1336
      }
1✔
1337
    }
1✔
1338
    return null;
×
1339
  }
1340

1341
  @Override
1342
  public boolean setWritable(Path file, boolean writable) {
1343

1344
    if (!Files.exists(file)) {
5✔
1345
      return false;
2✔
1346
    }
1347
    try {
1348
      // POSIX
1349
      PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class);
7✔
1350
      if (posix != null) {
2!
1351
        Set<PosixFilePermission> permissions = new HashSet<>(posix.readAttributes().permissions());
7✔
1352
        boolean changed;
1353
        if (writable) {
2!
1354
          changed = permissions.add(PosixFilePermission.OWNER_WRITE);
5✔
1355
        } else {
1356
          changed = permissions.remove(PosixFilePermission.OWNER_WRITE);
×
1357
        }
1358
        if (changed) {
2!
1359
          posix.setPermissions(permissions);
×
1360
        }
1361
        return true;
2✔
1362
      }
1363

1364
      // Windows
1365
      DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class);
×
1366
      if (dos != null) {
×
1367
        dos.setReadOnly(!writable);
×
1368
        return true;
×
1369
      }
1370

1371
      LOG.debug("Failed to set writing permission for file {}", file);
×
1372
      return false;
×
1373

1374
    } catch (IOException e) {
×
1375
      LOG.debug("Error occurred when trying to set writing permission for file {}: {}", file, e.toString(), e);
×
1376
      return false;
×
1377
    }
1378
  }
1379

1380
  @Override
1381
  public void makeExecutable(Path path, boolean confirm) {
1382

1383
    if (Files.exists(path)) {
5✔
1384
      if (skipPermissionsIfWindows(path)) {
4!
1385
        return;
×
1386
      }
1387
      PathPermissions existingPermissions = getFilePermissions(path);
4✔
1388
      PathPermissions executablePermissions = existingPermissions.makeExecutable();
3✔
1389
      boolean update = (executablePermissions != existingPermissions);
7✔
1390
      if (update) {
2✔
1391
        if (confirm) {
2!
1392
          boolean yesContinue = this.context.question(
×
1393
              "We want to execute {} but this command seems to lack executable permissions!\n"
1394
                  + "Most probably the tool vendor did forget to add x-flags in the binary release package.\n"
1395
                  + "Before running the command, we suggest to set executable permissions to the file:\n"
1396
                  + "{}\n"
1397
                  + "For security reasons we ask for your confirmation so please check this request.\n"
1398
                  + "Changing permissions from {} to {}.\n"
1399
                  + "Do you confirm to make the command executable before running it?", path.getFileName(), path, existingPermissions, executablePermissions);
×
1400
          if (!yesContinue) {
×
1401
            return;
×
1402
          }
1403
        }
1404
        setFilePermissions(path, executablePermissions, false);
6✔
1405
      } else {
1406
        LOG.trace("Executable flags already present so no need to set them for file {}", path);
4✔
1407
      }
1408
    } else {
1✔
1409
      LOG.warn("Cannot set executable flag on file that does not exist: {}", path);
4✔
1410
    }
1411
  }
1✔
1412

1413
  @Override
1414
  public PathPermissions getFilePermissions(Path path) {
1415

1416
    PathPermissions pathPermissions;
1417
    String info = "";
2✔
1418
    if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1419
      info = "mocked-";
×
1420
      if (Files.isDirectory(path)) {
×
1421
        pathPermissions = PathPermissions.MODE_RWX_RX_RX;
×
1422
      } else {
1423
        Path parent = path.getParent();
×
1424
        if ((parent != null) && (parent.getFileName().toString().equals("bin"))) {
×
1425
          pathPermissions = PathPermissions.MODE_RWX_RX_RX;
×
1426
        } else {
1427
          pathPermissions = PathPermissions.MODE_RW_R_R;
×
1428
        }
1429
      }
×
1430
    } else {
1431
      Set<PosixFilePermission> permissions;
1432
      try {
1433
        // Read the current file permissions
1434
        permissions = Files.getPosixFilePermissions(path);
5✔
1435
      } catch (IOException e) {
×
1436
        throw new RuntimeException("Failed to get permissions for " + path, e);
×
1437
      }
1✔
1438
      pathPermissions = PathPermissions.of(permissions);
3✔
1439
    }
1440
    LOG.trace("Read {}permissions of {} as {}.", info, path, pathPermissions);
17✔
1441
    return pathPermissions;
2✔
1442
  }
1443

1444
  @Override
1445
  public void setFilePermissions(Path path, PathPermissions permissions, boolean logErrorAndContinue) {
1446

1447
    if (skipPermissionsIfWindows(path)) {
4!
1448
      return;
×
1449
    }
1450
    try {
1451
      LOG.debug("Setting permissions for {} to {}", path, permissions);
5✔
1452
      // Set the new permissions
1453
      Files.setPosixFilePermissions(path, permissions.toPosix());
5✔
1454
    } catch (IOException e) {
×
1455
      String message = "Failed to set permissions to " + permissions + " for path " + path;
×
1456
      if (logErrorAndContinue) {
×
1457
        LOG.warn(message, e);
×
1458
      } else {
1459
        throw new RuntimeException(message, e);
×
1460
      }
1461
    }
1✔
1462
  }
1✔
1463

1464
  private boolean skipPermissionsIfWindows(Path path) {
1465

1466
    if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1467
      LOG.trace("Windows does not have file permissions hence omitting for {}", path);
×
1468
      return true;
×
1469
    }
1470
    return false;
2✔
1471
  }
1472

1473
  @Override
1474
  public void touch(Path file) {
1475

1476
    if (Files.exists(file)) {
5✔
1477
      try {
1478
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
1479
      } catch (IOException e) {
×
1480
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
1481
      }
1✔
1482
    } else {
1483
      try {
1484
        Files.createFile(file);
5✔
1485
      } catch (IOException e) {
1✔
1486
        throw new IllegalStateException("Could not create empty file " + file, e);
8✔
1487
      }
1✔
1488
    }
1489
  }
1✔
1490

1491
  @Override
1492
  public String readFileContent(Path file) {
1493

1494
    LOG.trace("Reading content of file from {}", file);
4✔
1495
    if (!Files.exists((file))) {
5✔
1496
      LOG.debug("File {} does not exist", file);
4✔
1497
      return null;
2✔
1498
    }
1499
    try {
1500
      String content = Files.readString(file);
3✔
1501
      LOG.trace("Completed reading {} character(s) from file {}", content.length(), file);
7✔
1502
      return content;
2✔
1503
    } catch (IOException e) {
×
1504
      throw new IllegalStateException("Failed to read file " + file, e);
×
1505
    }
1506
  }
1507

1508
  @Override
1509
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1510

1511
    if (createParentDir) {
2✔
1512
      mkdirs(file.getParent());
4✔
1513
    }
1514
    if (content == null) {
2!
1515
      content = "";
×
1516
    }
1517
    LOG.trace("Writing content with {} character(s) to file {}", content.length(), file);
7✔
1518
    if (Files.exists(file)) {
5✔
1519
      LOG.info("Overriding content of file {}", file);
4✔
1520
    }
1521
    try {
1522
      Files.writeString(file, content);
6✔
1523
      LOG.trace("Wrote content to file {}", file);
4✔
1524
    } catch (IOException e) {
×
1525
      throw new RuntimeException("Failed to write file " + file, e);
×
1526
    }
1✔
1527
  }
1✔
1528

1529
  @Override
1530
  public List<String> readFileLines(Path file) {
1531

1532
    LOG.trace("Reading lines of file from {}", file);
4✔
1533
    if (!Files.exists(file)) {
5✔
1534
      LOG.warn("File {} does not exist", file);
4✔
1535
      return null;
2✔
1536
    }
1537
    try {
1538
      List<String> content = Files.readAllLines(file);
3✔
1539
      LOG.trace("Completed reading {} lines from file {}", content.size(), file);
7✔
1540
      return content;
2✔
1541
    } catch (IOException e) {
×
1542
      throw new IllegalStateException("Failed to read file " + file, e);
×
1543
    }
1544
  }
1545

1546
  @Override
1547
  public void writeFileLines(List<String> content, Path file, boolean createParentDir) {
1548

1549
    if (createParentDir) {
2!
1550
      mkdirs(file.getParent());
×
1551
    }
1552
    if (content == null) {
2!
1553
      content = List.of();
×
1554
    }
1555
    LOG.trace("Writing content with {} lines to file {}", content.size(), file);
7✔
1556
    if (Files.exists(file)) {
5✔
1557
      LOG.debug("Overriding content of file {}", file);
4✔
1558
    }
1559
    try {
1560
      Files.write(file, content);
6✔
1561
      LOG.trace("Wrote lines to file {}", file);
4✔
1562
    } catch (IOException e) {
×
1563
      throw new RuntimeException("Failed to write file " + file, e);
×
1564
    }
1✔
1565
  }
1✔
1566

1567
  @Override
1568
  public void readProperties(Path file, Properties properties) {
1569

1570
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1571
      properties.load(reader);
3✔
1572
      LOG.debug("Successfully loaded {} properties from {}", properties.size(), file);
7✔
1573
    } catch (IOException e) {
×
1574
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1575
    }
1✔
1576
  }
1✔
1577

1578
  @Override
1579
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1580

1581
    if (createParentDir) {
2✔
1582
      mkdirs(file.getParent());
4✔
1583
    }
1584
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1585
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1586
      LOG.debug("Successfully saved {} properties to {}", properties.size(), file);
7✔
1587
    } catch (IOException e) {
×
1588
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1589
    }
1✔
1590
  }
1✔
1591

1592
  @Override
1593
  public void readIniFile(Path file, IniFile iniFile) {
1594

1595
    if (!Files.exists(file)) {
5✔
1596
      LOG.debug("INI file {} does not exist.", iniFile);
4✔
1597
      return;
1✔
1598
    }
1599
    List<String> iniLines = readFileLines(file);
4✔
1600
    IniSection currentIniSection = iniFile.getInitialSection();
3✔
1601
    for (String line : iniLines) {
10✔
1602
      if (line.trim().startsWith("[")) {
5✔
1603
        currentIniSection = iniFile.getOrCreateSection(line);
5✔
1604
      } else if (line.isBlank() || IniComment.COMMENT_SYMBOLS.contains(line.trim().charAt(0))) {
11✔
1605
        currentIniSection.addComment(line);
4✔
1606
      } else {
1607
        int index = line.indexOf('=');
4✔
1608
        if (index > 0) {
2!
1609
          currentIniSection.setPropertyLine(line);
3✔
1610
        }
1611
      }
1612
    }
1✔
1613
  }
1✔
1614

1615
  @Override
1616
  public void writeIniFile(IniFile iniFile, Path file, boolean createParentDir) {
1617

1618
    String iniString = iniFile.toString();
3✔
1619
    writeFileContent(iniString, file, createParentDir);
5✔
1620
  }
1✔
1621

1622
  @Override
1623
  public Duration getFileAge(Path path) {
1624

1625
    if (Files.exists(path)) {
5✔
1626
      try {
1627
        long currentTime = System.currentTimeMillis();
2✔
1628
        long fileModifiedTime = Files.getLastModifiedTime(path).toMillis();
6✔
1629
        return Duration.ofMillis(currentTime - fileModifiedTime);
5✔
1630
      } catch (IOException e) {
×
1631
        LOG.warn("Could not get modification-time of {}.", path, e);
×
1632
      }
×
1633
    } else {
1634
      LOG.debug("Path {} is missing - skipping modification-time and file age check.", path);
4✔
1635
    }
1636
    return null;
2✔
1637
  }
1638

1639
  @Override
1640
  public boolean isFileAgeRecent(Path path, Duration cacheDuration) {
1641

1642
    Duration age = getFileAge(path);
4✔
1643
    if (age == null) {
2✔
1644
      return false;
2✔
1645
    }
1646
    LOG.debug("The path {} was last updated {} ago and caching duration is {}.", path, age, cacheDuration);
17✔
1647
    return (age.toMillis() <= cacheDuration.toMillis());
10✔
1648
  }
1649
}
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