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

devonfw / IDEasy / 25176744938

30 Apr 2026 04:21PM UTC coverage: 70.647% (-0.02%) from 70.671%
25176744938

push

github

web-flow
#797: vscode zip symlink fix (#1875)

4378 of 6848 branches covered (63.93%)

Branch coverage included in aggregate %.

11302 of 15347 relevant lines covered (73.64%)

3.12 hits per line

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

67.84
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.URI;
11
import java.net.http.HttpClient.Version;
12
import java.net.http.HttpResponse;
13
import java.nio.file.FileSystem;
14
import java.nio.file.FileSystemException;
15
import java.nio.file.FileSystems;
16
import java.nio.file.Files;
17
import java.nio.file.LinkOption;
18
import java.nio.file.NoSuchFileException;
19
import java.nio.file.Path;
20
import java.nio.file.StandardCopyOption;
21
import java.nio.file.attribute.BasicFileAttributes;
22
import java.nio.file.attribute.DosFileAttributeView;
23
import java.nio.file.attribute.FileTime;
24
import java.nio.file.attribute.PosixFileAttributeView;
25
import java.nio.file.attribute.PosixFilePermission;
26
import java.security.DigestInputStream;
27
import java.security.MessageDigest;
28
import java.security.NoSuchAlgorithmException;
29
import java.time.Duration;
30
import java.time.LocalDateTime;
31
import java.util.ArrayList;
32
import java.util.HashSet;
33
import java.util.Iterator;
34
import java.util.List;
35
import java.util.Map;
36
import java.util.Objects;
37
import java.util.Properties;
38
import java.util.Set;
39
import java.util.function.Consumer;
40
import java.util.function.Function;
41
import java.util.function.Predicate;
42
import java.util.stream.Stream;
43

44
import org.apache.commons.compress.archivers.ArchiveEntry;
45
import org.apache.commons.compress.archivers.ArchiveInputStream;
46
import org.apache.commons.compress.archivers.ArchiveOutputStream;
47
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
48
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
49
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
50
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
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);
3✔
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 static final Map<String, String> FS_ENV = Map.of("encoding", "UTF-8");
5✔
85

86
  private final IdeContext context;
87

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

438
    assert !(isSymlink && isJunction);
5!
439

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

450
  /**
451
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
452
   * is applied to {@code target}.
453
   *
454
   * @param link the {@link Path} the link should point to and that is to be adapted.
455
   * @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
456
   *     {@code true}.
457
   * @param relative the {@code relative} flag.
458
   * @return the adapted {@link Path}.
459
   * @see #symlink(Path, Path, boolean)
460
   */
461
  private Path adaptPath(Path source, Path link, boolean relative) {
462

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

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

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

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

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

513
  private boolean runMklink(Path source, Path link, Path cwd, String option) {
514

515
    LOG.trace("Creating a Windows link with mklink {} at {} pointing to {}", option, link, source);
×
516
    ProcessContext pc = this.context.newProcess().executable("cmd").addArgs("/c", "mklink", option);
×
517
    if (cwd != null) {
×
518
      pc.directory(cwd);
×
519
    }
520
    ProcessResult result = pc.addArgs(link.toString(), source.toString()).run(ProcessMode.DEFAULT);
×
521
    try {
522
      result.failOnError();
×
523
      return true;
×
524
    } catch (RuntimeException e) {
×
525
      LOG.debug("mklink {} failed for {} -> {}: {}", option, link, source, e.getMessage());
×
526
      return false;
×
527
    }
528
  }
529

530
  @Override
531
  public void link(Path source, Path link, boolean relative, PathLinkType type) {
532

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

568
  @Override
569
  public Path getPathStart(Path path, int nameEnd) {
570

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

584
  @Override
585
  public Path getPathEnd(Path path, int nameStart) {
586

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

600
  @Override
601
  public int getCommonNameCount(Path path1, Path path2) {
602

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

619
  @Override
620
  public Path toRealPath(Path path) {
621

622
    return toRealPath(path, true);
5✔
623
  }
624

625
  @Override
626
  public Path toCanonicalPath(Path path) {
627

628
    return toRealPath(path, false);
5✔
629
  }
630

631
  private Path toRealPath(Path path, boolean resolveLinks) {
632

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

649
  @Override
650
  public Path createTempDir(String name) {
651

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

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

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

711
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
712

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

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

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

742
  @Override
743
  public void extractZip(Path file, Path targetDir) {
744

745
    LOG.info("Extracting ZIP file {} to {}", file, targetDir);
5✔
746
    if (this.context.getSystemInfo().isMac()) {
5✔
747
      extractZipWithSystemUnzip(file, targetDir);
5✔
748
    } else {
749
      extractZipWithJava(file, targetDir);
4✔
750
    }
751
  }
1✔
752

753
  private void extractZipWithSystemUnzip(Path file, Path targetDir) {
754

755
    mkdirs(targetDir);
3✔
756
    ProcessContext pc = this.context.newProcess();
4✔
757
    pc.executable("/usr/bin/unzip");
4✔
758
    pc.addArgs("-o", "-q", file, "-d", targetDir);
25✔
759
    pc.run();
3✔
760
  }
1✔
761

762
  private void extractZipWithJava(Path file, Path targetDir) {
763

764
    URI uri = URI.create("jar:" + file.toUri());
6✔
765
    try (FileSystem fs = FileSystems.newFileSystem(uri, FS_ENV)) {
4✔
766
      long size = 0;
2✔
767
      for (Path root : fs.getRootDirectories()) {
11✔
768
        size += getFileSizeRecursive(root);
6✔
769
      }
1✔
770
      try (final IdeProgressBar progressBar = this.context.newProgressbarForExtracting(size)) {
5✔
771
        for (Path root : fs.getRootDirectories()) {
11✔
772
          copy(root, targetDir, FileCopyMode.EXTRACT, (s, t, d) -> onFileCopiedFromZip(s, t, d, progressBar));
15✔
773
        }
1✔
774
      }
775
    } catch (IOException e) {
×
776
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
777
    }
1✔
778
  }
1✔
779

780
  @SuppressWarnings("unchecked")
781
  private void onFileCopiedFromZip(Path source, Path target, boolean directory, IdeProgressBar progressBar) {
782

783
    if (directory) {
2✔
784
      return;
1✔
785
    }
786
    if (!this.context.getSystemInfo().isWindows()) {
5✔
787
      try {
788
        Object attribute = Files.getAttribute(source, "zip:permissions");
6✔
789
        if (attribute instanceof Set<?> permissionSet) {
6✔
790
          Files.setPosixFilePermissions(target, (Set<PosixFilePermission>) permissionSet);
4✔
791
        }
792
      } catch (Exception e) {
×
793
        LOG.error("Failed to transfer zip permissions for {}", target, e);
×
794
      }
1✔
795
    }
796
    progressBar.stepBy(getFileSize(target));
5✔
797
  }
1✔
798

799
  @Override
800
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
801

802
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
803
  }
1✔
804

805
  @Override
806
  public void extractJar(Path file, Path targetDir) {
807

808
    extractZip(file, targetDir);
4✔
809
  }
1✔
810

811
  /**
812
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
813
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
814
   */
815
  public static String generatePermissionString(int permissions) {
816

817
    // Ensure that only the last 9 bits are considered
818
    permissions &= 0b111111111;
4✔
819

820
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
821
    for (int i = 0; i < 9; i++) {
7✔
822
      int mask = 1 << i;
4✔
823
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
824
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
825
    }
826

827
    return permissionStringBuilder.toString();
3✔
828
  }
829

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

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

834
    final List<PathLink> links = new ArrayList<>();
4✔
835
    try (InputStream is = Files.newInputStream(file);
5✔
836
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
837
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
838

839
      final Path root = targetDir.toAbsolutePath().normalize();
4✔
840

841
      ArchiveEntry entry = ais.getNextEntry();
3✔
842
      while (entry != null) {
2✔
843
        String entryName = entry.getName();
3✔
844
        Path entryPath = resolveRelativePathSecure(entryName, root);
5✔
845
        PathPermissions permissions = null;
2✔
846
        PathLinkType linkType = null;
2✔
847
        if (entry instanceof TarArchiveEntry tae) {
6!
848
          if (tae.isSymbolicLink()) {
3✔
849
            linkType = PathLinkType.SYMBOLIC_LINK;
3✔
850
          } else if (tae.isLink()) {
3!
851
            linkType = PathLinkType.HARD_LINK;
×
852
          }
853
          if (linkType == null) {
2✔
854
            permissions = PathPermissions.of(tae.getMode());
5✔
855
          } else {
856
            Path parent = entryPath.getParent();
3✔
857
            String sourcePathString = tae.getLinkName();
3✔
858
            Path source = parent.resolve(sourcePathString).normalize();
5✔
859
            source = resolveRelativePathSecure(source, root, sourcePathString);
6✔
860
            links.add(new PathLink(source, entryPath, linkType));
9✔
861
            mkdirs(parent);
3✔
862
          }
863
        }
864
        if (entry.isDirectory()) {
3✔
865
          mkdirs(entryPath);
4✔
866
        } else if (linkType == null) { // regular file
2✔
867
          mkdirs(entryPath.getParent());
4✔
868
          Files.copy(ais, entryPath, StandardCopyOption.REPLACE_EXISTING);
10✔
869
          // POSIX perms on non-Windows
870
          if (permissions != null) {
2!
871
            setFilePermissions(entryPath, permissions, false);
5✔
872
          }
873
        }
874
        pb.stepBy(Math.max(0L, entry.getSize()));
6✔
875
        entry = ais.getNextEntry();
3✔
876
      }
1✔
877
      // post process links
878
      for (PathLink link : links) {
10✔
879
        link(link);
3✔
880
      }
1✔
881
    } catch (Exception e) {
×
882
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
883
    }
1✔
884
  }
1✔
885

886
  private Path resolveRelativePathSecure(String entryName, Path root) {
887

888
    Path entryPath = root.resolve(entryName).normalize();
5✔
889
    return resolveRelativePathSecure(entryPath, root, entryName);
6✔
890
  }
891

892
  private Path resolveRelativePathSecure(Path entryPath, Path root, String entryName) {
893

894
    if (!entryPath.startsWith(root)) {
4!
895
      throw new IllegalStateException("Preventing path traversal attack from " + entryName + " to " + entryPath + " leaving " + root);
×
896
    }
897
    return entryPath;
2✔
898
  }
899

900
  @Override
901
  public void extractDmg(Path file, Path targetDir) {
902

903
    LOG.info("Extracting DMG file {} to {}", file, targetDir);
×
904
    assert this.context.getSystemInfo().isMac();
×
905

906
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
907
    mkdirs(mountPath);
×
908
    ProcessContext pc = this.context.newProcess();
×
909
    pc.executable("hdiutil");
×
910
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
911
    pc.run();
×
912
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
913
    if (appPath == null) {
×
914
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
915
    }
916

917
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
918
    pc.addArgs("detach", "-force", mountPath);
×
919
    pc.run();
×
920
  }
×
921

922
  @Override
923
  public void extractMsi(Path file, Path targetDir) {
924

925
    LOG.info("Extracting MSI file {} to {}", file, targetDir);
×
926
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
927
    // msiexec also creates a copy of the MSI
928
    Path msiCopy = targetDir.resolve(file.getFileName());
×
929
    delete(msiCopy);
×
930
  }
×
931

932
  @Override
933
  public void extractPkg(Path file, Path targetDir) {
934

935
    LOG.info("Extracting PKG file {} to {}", file, targetDir);
×
936
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
937
    ProcessContext pc = this.context.newProcess();
×
938
    // we might also be able to use cpio from commons-compression instead of external xar...
939
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
940
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
941
    extractTar(contentPath, targetDir, TarCompression.GZ);
×
942
    delete(tmpDirPkg);
×
943
  }
×
944

945
  @Override
946
  public void compress(Path dir, OutputStream out, String path) {
947

948
    String extension = FilenameUtil.getExtension(path);
3✔
949
    TarCompression tarCompression = TarCompression.of(extension);
3✔
950
    if (tarCompression != null) {
2!
951
      compressTar(dir, out, tarCompression);
6✔
952
    } else if (extension.equals("zip")) {
×
953
      compressZip(dir, out);
×
954
    } else {
955
      throw new IllegalArgumentException("Unsupported extension: " + extension);
×
956
    }
957
  }
1✔
958

959
  @Override
960
  public void compressTar(Path dir, OutputStream out, TarCompression tarCompression) {
961

962
    switch (tarCompression) {
8!
963
      case null -> compressTar(dir, out);
×
964
      case NONE -> compressTar(dir, out);
×
965
      case GZ -> compressTarGz(dir, out);
5✔
966
      case BZIP2 -> compressTarBzip2(dir, out);
×
967
      default -> throw new IllegalArgumentException("Unsupported tar compression: " + tarCompression);
×
968
    }
969
  }
1✔
970

971
  @Override
972
  public void compressTarGz(Path dir, OutputStream out) {
973

974
    try (GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(out)) {
5✔
975
      compressTarOrThrow(dir, gzOut);
4✔
976
    } catch (IOException e) {
×
977
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.gz file.", e);
×
978
    }
1✔
979
  }
1✔
980

981
  @Override
982
  public void compressTarBzip2(Path dir, OutputStream out) {
983

984
    try (BZip2CompressorOutputStream bzip2Out = new BZip2CompressorOutputStream(out)) {
×
985
      compressTarOrThrow(dir, bzip2Out);
×
986
    } catch (IOException e) {
×
987
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.bz2 file.", e);
×
988
    }
×
989
  }
×
990

991
  @Override
992
  public void compressTar(Path dir, OutputStream out) {
993

994
    try {
995
      compressTarOrThrow(dir, out);
×
996
    } catch (IOException e) {
×
997
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar file.", e);
×
998
    }
×
999
  }
×
1000

1001
  private void compressTarOrThrow(Path dir, OutputStream out) throws IOException {
1002

1003
    try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) {
5✔
1004
      compressRecursive(dir, tarOut, "");
5✔
1005
      tarOut.finish();
2✔
1006
    }
1007
  }
1✔
1008

1009
  @Override
1010
  public void compressZip(Path dir, OutputStream out) {
1011

1012
    try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(out)) {
×
1013
      compressRecursive(dir, zipOut, "");
×
1014
      zipOut.finish();
×
1015
    } catch (IOException e) {
×
1016
      throw new IllegalStateException("Failed to compress directory " + dir + " to zip file.", e);
×
1017
    }
×
1018
  }
×
1019

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

1022
    try (Stream<Path> childStream = Files.list(path)) {
3✔
1023
      Iterator<Path> iterator = childStream.iterator();
3✔
1024
      while (iterator.hasNext()) {
3✔
1025
        Path child = iterator.next();
4✔
1026
        String relativeChildPath = relativePath + "/" + child.getFileName().toString();
6✔
1027
        boolean isDirectory = Files.isDirectory(child);
5✔
1028
        E archiveEntry = out.createArchiveEntry(child, relativeChildPath);
7✔
1029
        if (archiveEntry instanceof TarArchiveEntry tarEntry) {
6!
1030
          FileTime none = FileTime.fromMillis(0);
3✔
1031
          tarEntry.setCreationTime(none);
3✔
1032
          tarEntry.setModTime(none);
3✔
1033
          tarEntry.setLastAccessTime(none);
3✔
1034
          tarEntry.setLastModifiedTime(none);
3✔
1035
          tarEntry.setUserId(0);
3✔
1036
          tarEntry.setUserName("user");
3✔
1037
          tarEntry.setGroupId(0);
3✔
1038
          tarEntry.setGroupName("group");
3✔
1039
          PathPermissions filePermissions = getFilePermissions(child);
4✔
1040
          tarEntry.setMode(filePermissions.toMode());
4✔
1041
        }
1042
        out.putArchiveEntry(archiveEntry);
3✔
1043
        if (!isDirectory) {
2✔
1044
          try (InputStream in = Files.newInputStream(child)) {
5✔
1045
            IOUtils.copy(in, out);
4✔
1046
          }
1047
        }
1048
        out.closeArchiveEntry();
2✔
1049
        if (isDirectory) {
2✔
1050
          compressRecursive(child, out, relativeChildPath);
5✔
1051
        }
1052
      }
1✔
1053
    } catch (IOException e) {
×
1054
      throw new IllegalStateException("Failed to compress " + path, e);
×
1055
    }
1✔
1056
  }
1✔
1057

1058
  @Override
1059
  public void delete(Path path) {
1060

1061
    if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
1062
      LOG.trace("Deleting {} skipped as the path does not exist.", path);
4✔
1063
      return;
1✔
1064
    }
1065
    LOG.debug("Deleting {} ...", path);
4✔
1066
    try {
1067
      if (isLink(path)) {
4✔
1068
        deletePath(path);
4✔
1069
      } else {
1070
        deleteRecursive(path);
3✔
1071
      }
1072
    } catch (IOException e) {
×
1073
      throw new IllegalStateException("Failed to delete " + path, e);
×
1074
    }
1✔
1075
  }
1✔
1076

1077
  private void deleteRecursive(Path path) throws IOException {
1078

1079
    if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
1080
      LOG.trace("Deleting link {} ...", path);
4✔
1081
      Files.delete(path);
2✔
1082
      return;
1✔
1083
    }
1084
    if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
1085
      try (Stream<Path> childStream = Files.list(path)) {
3✔
1086
        Iterator<Path> iterator = childStream.iterator();
3✔
1087
        while (iterator.hasNext()) {
3✔
1088
          Path child = iterator.next();
4✔
1089
          deleteRecursive(child);
3✔
1090
        }
1✔
1091
      }
1092
    }
1093
    deletePath(path);
3✔
1094
  }
1✔
1095

1096
  private boolean isLink(Path path) {
1097

1098
    return Files.isSymbolicLink(path) || isJunction(path);
11!
1099
  }
1100

1101
  private void deletePath(Path path) throws IOException {
1102

1103
    LOG.trace("Deleting {} ...", path);
4✔
1104
    boolean isSetWritable = setWritable(path, true);
5✔
1105
    if (!isSetWritable) {
2✔
1106
      LOG.debug("Couldn't give write access to file: {}", path);
4✔
1107
    }
1108
    Files.delete(path);
2✔
1109
  }
1✔
1110

1111
  @Override
1112
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
1113

1114
    try {
1115
      if (!Files.isDirectory(dir)) {
5!
1116
        return null;
×
1117
      }
1118
      return findFirstRecursive(dir, filter, recursive);
6✔
1119
    } catch (IOException e) {
×
1120
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
1121
    }
1122
  }
1123

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

1126
    List<Path> folders = null;
2✔
1127
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1128
      Iterator<Path> iterator = childStream.iterator();
3✔
1129
      while (iterator.hasNext()) {
3✔
1130
        Path child = iterator.next();
4✔
1131
        if (filter.test(child)) {
4✔
1132
          return child;
4✔
1133
        } else if (recursive && Files.isDirectory(child)) {
2!
1134
          if (folders == null) {
×
1135
            folders = new ArrayList<>();
×
1136
          }
1137
          folders.add(child);
×
1138
        }
1139
      }
1✔
1140
    }
4!
1141
    if (folders != null) {
2!
1142
      for (Path child : folders) {
×
1143
        Path match = findFirstRecursive(child, filter, recursive);
×
1144
        if (match != null) {
×
1145
          return match;
×
1146
        }
1147
      }
×
1148
    }
1149
    return null;
2✔
1150
  }
1151

1152
  @Override
1153
  public Path findAncestor(Path path, Path baseDir, int subfolderCount) {
1154

1155
    if ((path == null) || (baseDir == null)) {
4!
1156
      LOG.debug("Path should not be null for findAncestor.");
3✔
1157
      return null;
2✔
1158
    }
1159
    if (subfolderCount <= 0) {
2!
1160
      throw new IllegalArgumentException("Subfolder count: " + subfolderCount);
×
1161
    }
1162
    // 1. option relativize
1163
    // 2. recursive getParent
1164
    // 3. loop getParent???
1165
    // 4. getName + getNameCount
1166
    path = path.toAbsolutePath().normalize();
4✔
1167
    baseDir = baseDir.toAbsolutePath().normalize();
4✔
1168
    int directoryNameCount = path.getNameCount();
3✔
1169
    int baseDirNameCount = baseDir.getNameCount();
3✔
1170
    int delta = directoryNameCount - baseDirNameCount - subfolderCount;
6✔
1171
    if (delta < 0) {
2!
1172
      return null;
×
1173
    }
1174
    // ensure directory is a sub-folder of baseDir
1175
    for (int i = 0; i < baseDirNameCount; i++) {
7✔
1176
      if (!path.getName(i).toString().equals(baseDir.getName(i).toString())) {
10✔
1177
        return null;
2✔
1178
      }
1179
    }
1180
    Path result = path;
2✔
1181
    while (delta > 0) {
2✔
1182
      result = result.getParent();
3✔
1183
      delta--;
2✔
1184
    }
1185
    return result;
2✔
1186
  }
1187

1188
  @Override
1189
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
1190

1191
    if (!Files.isDirectory(dir)) {
5✔
1192
      return List.of();
2✔
1193
    }
1194
    List<Path> children = new ArrayList<>();
4✔
1195
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1196
      Iterator<Path> iterator = childStream.iterator();
3✔
1197
      while (iterator.hasNext()) {
3✔
1198
        Path child = iterator.next();
4✔
1199
        Path filteredChild = filter.apply(child);
5✔
1200
        if (filteredChild != null) {
2✔
1201
          if (filteredChild == child) {
3!
1202
            LOG.trace("Accepted file {}", child);
5✔
1203
          } else {
1204
            LOG.trace("Accepted file {} and mapped to {}", child, filteredChild);
×
1205
          }
1206
          children.add(filteredChild);
5✔
1207
        } else {
1208
          LOG.trace("Ignoring file {} according to filter", child);
4✔
1209
        }
1210
      }
1✔
1211
    } catch (IOException e) {
×
1212
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
1213
    }
1✔
1214
    return children;
2✔
1215
  }
1216

1217
  @Override
1218
  public boolean isEmptyDir(Path dir) {
1219

1220
    return listChildren(dir, f -> true).isEmpty();
6✔
1221
  }
1222

1223
  @Override
1224
  public boolean isNonEmptyFile(Path file) {
1225

1226
    if (Files.isRegularFile(file)) {
5✔
1227
      return (getFileSize(file) > 0);
10✔
1228
    }
1229
    return false;
2✔
1230
  }
1231

1232
  private long getFileSize(Path file) {
1233

1234
    try {
1235
      return Files.size(file);
3✔
1236
    } catch (IOException e) {
×
1237
      LOG.warn("Failed to determine size of file {}: {}", file, e.toString(), e);
×
1238
      return 0;
×
1239
    }
1240
  }
1241

1242
  private long getFileSizeRecursive(Path path) {
1243

1244
    long size = 0;
2✔
1245
    if (Files.isDirectory(path)) {
5✔
1246
      try (Stream<Path> childStream = Files.list(path)) {
3✔
1247
        Iterator<Path> iterator = childStream.iterator();
3✔
1248
        while (iterator.hasNext()) {
3✔
1249
          Path child = iterator.next();
4✔
1250
          size += getFileSizeRecursive(child);
6✔
1251
        }
1✔
1252
      } catch (IOException e) {
×
1253
        throw new RuntimeException("Failed to iterate children of folder " + path, e);
×
1254
      }
1✔
1255
    } else {
1256
      size += getFileSize(path);
6✔
1257
    }
1258
    return size;
2✔
1259
  }
1260

1261
  @Override
1262
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
1263

1264
    for (Path dir : searchDirs) {
10!
1265
      Path filePath = dir.resolve(fileName);
4✔
1266
      try {
1267
        if (Files.exists(filePath)) {
5✔
1268
          return filePath;
2✔
1269
        }
1270
      } catch (Exception e) {
×
1271
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
1272
      }
1✔
1273
    }
1✔
1274
    return null;
×
1275
  }
1276

1277
  @Override
1278
  public boolean setWritable(Path file, boolean writable) {
1279

1280
    if (!Files.exists(file)) {
5✔
1281
      return false;
2✔
1282
    }
1283
    try {
1284
      // POSIX
1285
      PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class);
7✔
1286
      if (posix != null) {
2!
1287
        Set<PosixFilePermission> permissions = new HashSet<>(posix.readAttributes().permissions());
7✔
1288
        boolean changed;
1289
        if (writable) {
2!
1290
          changed = permissions.add(PosixFilePermission.OWNER_WRITE);
5✔
1291
        } else {
1292
          changed = permissions.remove(PosixFilePermission.OWNER_WRITE);
×
1293
        }
1294
        if (changed) {
2!
1295
          posix.setPermissions(permissions);
×
1296
        }
1297
        return true;
2✔
1298
      }
1299

1300
      // Windows
1301
      DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class);
×
1302
      if (dos != null) {
×
1303
        dos.setReadOnly(!writable);
×
1304
        return true;
×
1305
      }
1306

1307
      LOG.debug("Failed to set writing permission for file {}", file);
×
1308
      return false;
×
1309

1310
    } catch (IOException e) {
×
1311
      LOG.debug("Error occurred when trying to set writing permission for file {}: {}", file, e.toString(), e);
×
1312
      return false;
×
1313
    }
1314
  }
1315

1316
  @Override
1317
  public void makeExecutable(Path path, boolean confirm) {
1318

1319
    if (Files.exists(path)) {
5✔
1320
      if (skipPermissionsIfWindows(path)) {
4!
1321
        return;
×
1322
      }
1323
      PathPermissions existingPermissions = getFilePermissions(path);
4✔
1324
      PathPermissions executablePermissions = existingPermissions.makeExecutable();
3✔
1325
      boolean update = (executablePermissions != existingPermissions);
7✔
1326
      if (update) {
2✔
1327
        if (confirm) {
2!
1328
          boolean yesContinue = this.context.question(
×
1329
              "We want to execute {} but this command seems to lack executable permissions!\n"
1330
                  + "Most probably the tool vendor did forget to add x-flags in the binary release package.\n"
1331
                  + "Before running the command, we suggest to set executable permissions to the file:\n"
1332
                  + "{}\n"
1333
                  + "For security reasons we ask for your confirmation so please check this request.\n"
1334
                  + "Changing permissions from {} to {}.\n"
1335
                  + "Do you confirm to make the command executable before running it?", path.getFileName(), path, existingPermissions, executablePermissions);
×
1336
          if (!yesContinue) {
×
1337
            return;
×
1338
          }
1339
        }
1340
        setFilePermissions(path, executablePermissions, false);
6✔
1341
      } else {
1342
        LOG.trace("Executable flags already present so no need to set them for file {}", path);
4✔
1343
      }
1344
    } else {
1✔
1345
      LOG.warn("Cannot set executable flag on file that does not exist: {}", path);
4✔
1346
    }
1347
  }
1✔
1348

1349
  @Override
1350
  public PathPermissions getFilePermissions(Path path) {
1351

1352
    PathPermissions pathPermissions;
1353
    String info = "";
2✔
1354
    if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1355
      info = "mocked-";
×
1356
      if (Files.isDirectory(path)) {
×
1357
        pathPermissions = PathPermissions.MODE_RWX_RX_RX;
×
1358
      } else {
1359
        Path parent = path.getParent();
×
1360
        if ((parent != null) && (parent.getFileName().toString().equals("bin"))) {
×
1361
          pathPermissions = PathPermissions.MODE_RWX_RX_RX;
×
1362
        } else {
1363
          pathPermissions = PathPermissions.MODE_RW_R_R;
×
1364
        }
1365
      }
×
1366
    } else {
1367
      Set<PosixFilePermission> permissions;
1368
      try {
1369
        // Read the current file permissions
1370
        permissions = Files.getPosixFilePermissions(path);
5✔
1371
      } catch (IOException e) {
×
1372
        throw new RuntimeException("Failed to get permissions for " + path, e);
×
1373
      }
1✔
1374
      pathPermissions = PathPermissions.of(permissions);
3✔
1375
    }
1376
    LOG.trace("Read {}permissions of {} as {}.", info, path, pathPermissions);
17✔
1377
    return pathPermissions;
2✔
1378
  }
1379

1380
  @Override
1381
  public void setFilePermissions(Path path, PathPermissions permissions, boolean logErrorAndContinue) {
1382

1383
    if (skipPermissionsIfWindows(path)) {
4!
1384
      return;
×
1385
    }
1386
    try {
1387
      LOG.debug("Setting permissions for {} to {}", path, permissions);
5✔
1388
      // Set the new permissions
1389
      Files.setPosixFilePermissions(path, permissions.toPosix());
5✔
1390
    } catch (IOException e) {
×
1391
      String message = "Failed to set permissions to " + permissions + " for path " + path;
×
1392
      if (logErrorAndContinue) {
×
1393
        LOG.warn(message, e);
×
1394
      } else {
1395
        throw new RuntimeException(message, e);
×
1396
      }
1397
    }
1✔
1398
  }
1✔
1399

1400
  private boolean skipPermissionsIfWindows(Path path) {
1401

1402
    if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1403
      LOG.trace("Windows does not have file permissions hence omitting for {}", path);
×
1404
      return true;
×
1405
    }
1406
    return false;
2✔
1407
  }
1408

1409
  @Override
1410
  public void touch(Path file) {
1411

1412
    if (Files.exists(file)) {
5✔
1413
      try {
1414
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
1415
      } catch (IOException e) {
×
1416
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
1417
      }
1✔
1418
    } else {
1419
      try {
1420
        Files.createFile(file);
5✔
1421
      } catch (IOException e) {
1✔
1422
        throw new IllegalStateException("Could not create empty file " + file, e);
8✔
1423
      }
1✔
1424
    }
1425
  }
1✔
1426

1427
  @Override
1428
  public String readFileContent(Path file) {
1429

1430
    LOG.trace("Reading content of file from {}", file);
4✔
1431
    if (!Files.exists((file))) {
5✔
1432
      LOG.debug("File {} does not exist", file);
4✔
1433
      return null;
2✔
1434
    }
1435
    try {
1436
      String content = Files.readString(file);
3✔
1437
      LOG.trace("Completed reading {} character(s) from file {}", content.length(), file);
7✔
1438
      return content;
2✔
1439
    } catch (IOException e) {
×
1440
      throw new IllegalStateException("Failed to read file " + file, e);
×
1441
    }
1442
  }
1443

1444
  @Override
1445
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1446

1447
    if (createParentDir) {
2✔
1448
      mkdirs(file.getParent());
4✔
1449
    }
1450
    if (content == null) {
2!
1451
      content = "";
×
1452
    }
1453
    LOG.trace("Writing content with {} character(s) to file {}", content.length(), file);
7✔
1454
    if (Files.exists(file)) {
5✔
1455
      LOG.info("Overriding content of file {}", file);
4✔
1456
    }
1457
    try {
1458
      Files.writeString(file, content);
6✔
1459
      LOG.trace("Wrote content to file {}", file);
4✔
1460
    } catch (IOException e) {
×
1461
      throw new RuntimeException("Failed to write file " + file, e);
×
1462
    }
1✔
1463
  }
1✔
1464

1465
  @Override
1466
  public List<String> readFileLines(Path file) {
1467

1468
    LOG.trace("Reading lines of file from {}", file);
4✔
1469
    if (!Files.exists(file)) {
5✔
1470
      LOG.warn("File {} does not exist", file);
4✔
1471
      return null;
2✔
1472
    }
1473
    try {
1474
      List<String> content = Files.readAllLines(file);
3✔
1475
      LOG.trace("Completed reading {} lines from file {}", content.size(), file);
7✔
1476
      return content;
2✔
1477
    } catch (IOException e) {
×
1478
      throw new IllegalStateException("Failed to read file " + file, e);
×
1479
    }
1480
  }
1481

1482
  @Override
1483
  public void writeFileLines(List<String> content, Path file, boolean createParentDir) {
1484

1485
    if (createParentDir) {
2!
1486
      mkdirs(file.getParent());
×
1487
    }
1488
    if (content == null) {
2!
1489
      content = List.of();
×
1490
    }
1491
    LOG.trace("Writing content with {} lines to file {}", content.size(), file);
7✔
1492
    if (Files.exists(file)) {
5✔
1493
      LOG.debug("Overriding content of file {}", file);
4✔
1494
    }
1495
    try {
1496
      Files.write(file, content);
6✔
1497
      LOG.trace("Wrote lines to file {}", file);
4✔
1498
    } catch (IOException e) {
×
1499
      throw new RuntimeException("Failed to write file " + file, e);
×
1500
    }
1✔
1501
  }
1✔
1502

1503
  @Override
1504
  public void readProperties(Path file, Properties properties) {
1505

1506
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1507
      properties.load(reader);
3✔
1508
      LOG.debug("Successfully loaded {} properties from {}", properties.size(), file);
7✔
1509
    } catch (IOException e) {
×
1510
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1511
    }
1✔
1512
  }
1✔
1513

1514
  @Override
1515
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1516

1517
    if (createParentDir) {
2✔
1518
      mkdirs(file.getParent());
4✔
1519
    }
1520
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1521
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1522
      LOG.debug("Successfully saved {} properties to {}", properties.size(), file);
7✔
1523
    } catch (IOException e) {
×
1524
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1525
    }
1✔
1526
  }
1✔
1527

1528
  @Override
1529
  public void readIniFile(Path file, IniFile iniFile) {
1530

1531
    if (!Files.exists(file)) {
5✔
1532
      LOG.debug("INI file {} does not exist.", iniFile);
4✔
1533
      return;
1✔
1534
    }
1535
    List<String> iniLines = readFileLines(file);
4✔
1536
    IniSection currentIniSection = iniFile.getInitialSection();
3✔
1537
    for (String line : iniLines) {
10✔
1538
      if (line.trim().startsWith("[")) {
5✔
1539
        currentIniSection = iniFile.getOrCreateSection(line);
5✔
1540
      } else if (line.isBlank() || IniComment.COMMENT_SYMBOLS.contains(line.trim().charAt(0))) {
11✔
1541
        currentIniSection.addComment(line);
4✔
1542
      } else {
1543
        int index = line.indexOf('=');
4✔
1544
        if (index > 0) {
2!
1545
          currentIniSection.setPropertyLine(line);
3✔
1546
        }
1547
      }
1548
    }
1✔
1549
  }
1✔
1550

1551
  @Override
1552
  public void writeIniFile(IniFile iniFile, Path file, boolean createParentDir) {
1553

1554
    String iniString = iniFile.toString();
3✔
1555
    writeFileContent(iniString, file, createParentDir);
5✔
1556
  }
1✔
1557

1558
  @Override
1559
  public Duration getFileAge(Path path) {
1560

1561
    if (Files.exists(path)) {
5✔
1562
      try {
1563
        long currentTime = System.currentTimeMillis();
2✔
1564
        long fileModifiedTime = Files.getLastModifiedTime(path).toMillis();
6✔
1565
        return Duration.ofMillis(currentTime - fileModifiedTime);
5✔
1566
      } catch (IOException e) {
×
1567
        LOG.warn("Could not get modification-time of {}.", path, e);
×
1568
      }
×
1569
    } else {
1570
      LOG.debug("Path {} is missing - skipping modification-time and file age check.", path);
4✔
1571
    }
1572
    return null;
2✔
1573
  }
1574

1575
  @Override
1576
  public boolean isFileAgeRecent(Path path, Duration cacheDuration) {
1577

1578
    Duration age = getFileAge(path);
4✔
1579
    if (age == null) {
2✔
1580
      return false;
2✔
1581
    }
1582
    LOG.debug("The path {} was last updated {} ago and caching duration is {}.", path, age, cacheDuration);
17✔
1583
    return (age.toMillis() <= cacheDuration.toMillis());
10✔
1584
  }
1585
}
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