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

devonfw / IDEasy / 17370052610

01 Sep 2025 06:54AM UTC coverage: 68.444% (-0.3%) from 68.717%
17370052610

Pull #1463

github

web-flow
Merge 9f64fb801 into 59ffaf27e
Pull Request #1463: #1454: Add ng commandlet

3399 of 5435 branches covered (62.54%)

Branch coverage included in aggregate %.

8847 of 12457 relevant lines covered (71.02%)

3.12 hits per line

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

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

49
import org.apache.commons.compress.archivers.ArchiveEntry;
50
import org.apache.commons.compress.archivers.ArchiveInputStream;
51
import org.apache.commons.compress.archivers.ArchiveOutputStream;
52
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
53
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
54
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
55
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
56
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream;
57
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
58
import org.apache.commons.io.IOUtils;
59

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

73
/**
74
 * Implementation of {@link FileAccess}.
75
 */
76
public class FileAccessImpl implements FileAccess {
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 int MODE_RWX_RX_RX = 0755;
85
  private static final int MODE_RW_R_R = 0644;
86

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

89
  private final IdeContext context;
90

91
  /**
92
   * The constructor.
93
   *
94
   * @param context the {@link IdeContext} to use.
95
   */
96
  public FileAccessImpl(IdeContext context) {
97

98
    super();
2✔
99
    this.context = context;
3✔
100
  }
1✔
101

102
  private HttpClient createHttpClient(String url) {
103

104
    return HttpClient.newBuilder().followRedirects(Redirect.ALWAYS).build();
5✔
105
  }
106

107
  @Override
108
  public void download(String url, Path target) {
109

110
    if (this.context.isOffline()) {
4!
111
      throw CliOfflineException.ofDownloadViaUrl(url);
×
112
    }
113
    if (url.startsWith("http")) {
4✔
114
      downloadViaHttp(url, target);
5✔
115
    } else if (url.startsWith("ftp") || url.startsWith("sftp")) {
8!
116
      throw new IllegalArgumentException("Unsupported download URL: " + url);
×
117
    } else {
118
      Path source = Path.of(url);
5✔
119
      if (isFile(source)) {
4!
120
        // network drive
121
        copyFileWithProgressBar(source, target);
5✔
122
      } else {
123
        throw new IllegalArgumentException("Download path does not point to a downloadable file: " + url);
×
124
      }
125
    }
126
  }
1✔
127

128
  private void downloadViaHttp(String url, Path target) {
129
    List<Version> httpProtocols = IdeVariables.HTTP_VERSIONS.get(context);
6✔
130
    Exception lastException = null;
2✔
131
    if (httpProtocols.isEmpty()) {
3!
132
      try {
133
        this.downloadWithHttpVersion(url, target, null);
5✔
134
        return;
1✔
135
      } catch (Exception e) {
×
136
        lastException = e;
×
137
      }
×
138
    } else {
139
      for (Version version : httpProtocols) {
×
140
        try {
141
          this.downloadWithHttpVersion(url, target, version);
×
142
          return;
×
143
        } catch (Exception ex) {
×
144
          lastException = ex;
×
145
        }
146
      }
×
147
    }
148
    throw new IllegalStateException("Failed to download file from URL " + url + " to " + target, lastException);
×
149
  }
150

151
  private void downloadWithHttpVersion(String url, Path target, Version httpVersion) throws Exception {
152

153
    if (httpVersion == null) {
2!
154
      this.context.info("Trying to download {} from {}", target.getFileName(), url);
16✔
155
    } else {
156
      this.context.info("Trying to download: {} with HTTP protocol version: {}", url, httpVersion);
×
157
    }
158
    mkdirs(target.getParent());
4✔
159

160
    Builder builder = HttpRequest.newBuilder()
2✔
161
        .uri(URI.create(url))
2✔
162
        .GET();
2✔
163
    if (httpVersion != null) {
2!
164
      builder.version(httpVersion);
×
165
    }
166
    try (HttpClient client = createHttpClient(url)) {
4✔
167
      HttpRequest request = builder.build();
3✔
168
      HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
5✔
169
      int statusCode = response.statusCode();
3✔
170
      if (statusCode == 200) {
3!
171
        downloadFileWithProgressBar(url, target, response);
6✔
172
      } else {
173
        throw new IllegalStateException("Download failed with status code " + statusCode);
×
174
      }
175
    }
176
  }
1✔
177

178
  /**
179
   * Downloads a file while showing a {@link IdeProgressBar}.
180
   *
181
   * @param url the url to download.
182
   * @param target Path of the target directory.
183
   * @param response the {@link HttpResponse} to use.
184
   */
185
  private void downloadFileWithProgressBar(String url, Path target, HttpResponse<InputStream> response) {
186

187
    long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1);
7✔
188
    informAboutMissingContentLength(contentLength, url);
4✔
189

190
    byte[] data = new byte[1024];
3✔
191
    boolean fileComplete = false;
2✔
192
    int count;
193

194
    try (InputStream body = response.body();
4✔
195
        FileOutputStream fileOutput = new FileOutputStream(target.toFile());
6✔
196
        BufferedOutputStream bufferedOut = new BufferedOutputStream(fileOutput, data.length);
7✔
197
        IdeProgressBar pb = this.context.newProgressBarForDownload(contentLength)) {
5✔
198
      while (!fileComplete) {
2✔
199
        count = body.read(data);
4✔
200
        if (count <= 0) {
2✔
201
          fileComplete = true;
3✔
202
        } else {
203
          bufferedOut.write(data, 0, count);
5✔
204
          pb.stepBy(count);
5✔
205
        }
206
      }
207

208
    } catch (Exception e) {
×
209
      throw new RuntimeException(e);
×
210
    }
1✔
211
  }
1✔
212

213
  private void copyFileWithProgressBar(Path source, Path target) {
214

215
    long size = getFileSize(source);
4✔
216
    if (size < 100_000) {
4✔
217
      copy(source, target, FileCopyMode.COPY_FILE_TO_TARGET_OVERRIDE);
5✔
218
      return;
1✔
219
    }
220
    try (InputStream in = Files.newInputStream(source);
5✔
221
        OutputStream out = Files.newOutputStream(target)) {
5✔
222
      byte[] buf = new byte[1024];
3✔
223
      try (IdeProgressBar pb = this.context.newProgressbarForCopying(size)) {
5✔
224
        int readBytes;
225
        while ((readBytes = in.read(buf)) > 0) {
6✔
226
          out.write(buf, 0, readBytes);
5✔
227
          pb.stepBy(readBytes);
5✔
228
        }
229
      } catch (Exception e) {
×
230
        throw new RuntimeException(e);
×
231
      }
1✔
232
    } catch (IOException e) {
×
233
      throw new RuntimeException("Failed to copy from " + source + " to " + target, e);
×
234
    }
1✔
235
  }
1✔
236

237
  private void informAboutMissingContentLength(long contentLength, String url) {
238

239
    if (contentLength < 0) {
4✔
240
      this.context.warning("Content-Length was not provided by download from {}", url);
10✔
241
    }
242
  }
1✔
243

244
  @Override
245
  public void mkdirs(Path directory) {
246

247
    if (Files.isDirectory(directory)) {
5✔
248
      return;
1✔
249
    }
250
    this.context.trace("Creating directory {}", directory);
10✔
251
    try {
252
      Files.createDirectories(directory);
5✔
253
    } catch (IOException e) {
×
254
      throw new IllegalStateException("Failed to create directory " + directory, e);
×
255
    }
1✔
256
  }
1✔
257

258
  @Override
259
  public boolean isFile(Path file) {
260

261
    if (!Files.exists(file)) {
5!
262
      this.context.trace("File {} does not exist", file);
×
263
      return false;
×
264
    }
265
    if (Files.isDirectory(file)) {
5!
266
      this.context.trace("Path {} is a directory but a regular file was expected", file);
×
267
      return false;
×
268
    }
269
    return true;
2✔
270
  }
271

272
  @Override
273
  public boolean isExpectedFolder(Path folder) {
274

275
    if (Files.isDirectory(folder)) {
5✔
276
      return true;
2✔
277
    }
278
    this.context.warning("Expected folder was not found at {}", folder);
10✔
279
    return false;
2✔
280
  }
281

282
  @Override
283
  public String checksum(Path file, String hashAlgorithm) {
284

285
    MessageDigest md;
286
    try {
287
      md = MessageDigest.getInstance(hashAlgorithm);
×
288
    } catch (NoSuchAlgorithmException e) {
×
289
      throw new IllegalStateException("No such hash algorithm " + hashAlgorithm, e);
×
290
    }
×
291
    byte[] buffer = new byte[1024];
×
292
    try (InputStream is = Files.newInputStream(file); DigestInputStream dis = new DigestInputStream(is, md)) {
×
293
      int read = 0;
×
294
      while (read >= 0) {
×
295
        read = dis.read(buffer);
×
296
      }
297
    } catch (Exception e) {
×
298
      throw new IllegalStateException("Failed to read and hash file " + file, e);
×
299
    }
×
300
    byte[] digestBytes = md.digest();
×
301
    return HexUtil.toHexString(digestBytes);
×
302
  }
303

304
  public boolean isJunction(Path path) {
305

306
    if (!SystemInfoImpl.INSTANCE.isWindows()) {
3!
307
      return false;
2✔
308
    }
309

310
    try {
311
      BasicFileAttributes attr = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
×
312
      return attr.isOther() && attr.isDirectory();
×
313
    } catch (NoSuchFileException e) {
×
314
      return false; // file doesn't exist
×
315
    } catch (IOException e) {
×
316
      // errors in reading the attributes of the file
317
      throw new IllegalStateException("An unexpected error occurred whilst checking if the file: " + path + " is a junction", e);
×
318
    }
319
  }
320

321
  @Override
322
  public Path backup(Path fileOrFolder) {
323

324
    if ((fileOrFolder != null) && (Files.isSymbolicLink(fileOrFolder) || isJunction(fileOrFolder))) {
9!
325
      delete(fileOrFolder);
4✔
326
    } else if ((fileOrFolder != null) && Files.exists(fileOrFolder)) {
7!
327
      LocalDateTime now = LocalDateTime.now();
2✔
328
      String date = DateTimeUtil.formatDate(now, true);
4✔
329
      String time = DateTimeUtil.formatTime(now);
3✔
330
      String filename = fileOrFolder.getFileName().toString();
4✔
331
      Path backupPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_BACKUPS).resolve(date).resolve(time + "_" + filename);
12✔
332
      backupPath = appendParentPath(backupPath, fileOrFolder.getParent(), 2);
6✔
333
      mkdirs(backupPath);
3✔
334
      Path target = backupPath.resolve(filename);
4✔
335
      this.context.info("Creating backup by moving {} to {}", fileOrFolder, target);
14✔
336
      move(fileOrFolder, target);
6✔
337
      return target;
2✔
338
    } else {
339
      this.context.trace("Backup of {} skipped as the path does not exist.", fileOrFolder);
10✔
340
    }
341
    return fileOrFolder;
2✔
342
  }
343

344
  private static Path appendParentPath(Path path, Path parent, int max) {
345

346
    if ((parent == null) || (max <= 0)) {
4!
347
      return path;
2✔
348
    }
349
    return appendParentPath(path, parent.getParent(), max - 1).resolve(parent.getFileName());
11✔
350
  }
351

352
  @Override
353
  public void move(Path source, Path targetDir, StandardCopyOption... copyOptions) {
354

355
    this.context.trace("Moving {} to {}", source, targetDir);
14✔
356
    try {
357
      Files.move(source, targetDir, copyOptions);
5✔
358
    } catch (IOException e) {
×
359
      String fileType = Files.isSymbolicLink(source) ? "symlink" : isJunction(source) ? "junction" : Files.isDirectory(source) ? "directory" : "file";
×
360
      String message = "Failed to move " + fileType + ": " + source + " to " + targetDir + ".";
×
361
      if (this.context.getSystemInfo().isWindows()) {
×
362
        message = message + "\n" + WINDOWS_FILE_LOCK_WARNING;
×
363
      }
364
      throw new IllegalStateException(message, e);
×
365
    }
1✔
366
  }
1✔
367

368
  @Override
369
  public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) {
370

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

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

415
    if (Files.isDirectory(source)) {
5✔
416
      mkdirs(target);
3✔
417
      try (Stream<Path> childStream = Files.list(source)) {
3✔
418
        Iterator<Path> iterator = childStream.iterator();
3✔
419
        while (iterator.hasNext()) {
3✔
420
          Path child = iterator.next();
4✔
421
          copyRecursive(child, target.resolve(child.getFileName().toString()), mode, listener);
10✔
422
        }
1✔
423
      }
424
      listener.onCopy(source, target, true);
6✔
425
    } else if (Files.exists(source)) {
5!
426
      if (mode.isOverride()) {
3✔
427
        delete(target);
3✔
428
      }
429
      this.context.trace("Starting to {} {} to {}", mode.getOperation(), source, target);
19✔
430
      Files.copy(source, target);
6✔
431
      listener.onCopy(source, target, false);
6✔
432
    } else {
433
      throw new IOException("Path " + source + " does not exist.");
×
434
    }
435
  }
1✔
436

437
  /**
438
   * 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
439
   * {@link Path} that is neither a symbolic link nor a Windows junction.
440
   *
441
   * @param path the {@link Path} to delete.
442
   * @throws IOException if the actual {@link Files#delete(Path) deletion} fails.
443
   */
444
  private void deleteLinkIfExists(Path path) throws IOException {
445

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

449
    assert !(isSymlink && isJunction);
5!
450

451
    if (isJunction || isSymlink) {
4!
452
      this.context.info("Deleting previous " + (isJunction ? "junction" : "symlink") + " at " + path);
9!
453
      Files.delete(path);
2✔
454
    }
455
  }
1✔
456

457
  /**
458
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
459
   * is applied to {@code source}.
460
   *
461
   * @param source the {@link Path} to adapt.
462
   * @param targetLink the {@link Path} used to calculate the relative path to the {@code source} if {@code relative} is set to {@code true}.
463
   * @param relative the {@code relative} flag.
464
   * @return the adapted {@link Path}.
465
   * @see FileAccessImpl#symlink(Path, Path, boolean)
466
   */
467
  private Path adaptPath(Path source, Path targetLink, boolean relative) throws IOException {
468

469
    if (source.isAbsolute()) {
3✔
470
      try {
471
        source = source.toRealPath(LinkOption.NOFOLLOW_LINKS); // to transform ../d1/../d2 to ../d2
9✔
472
      } catch (IOException e) {
×
473
        throw new IOException("Calling toRealPath() on the source (" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
474
      }
1✔
475
      if (relative) {
2✔
476
        source = targetLink.getParent().relativize(source);
5✔
477
        // to make relative links like this work: dir/link -> dir
478
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
479
      }
480
    } else { // source is relative
481
      if (relative) {
2✔
482
        // even though the source is already relative, toRealPath should be called to transform paths like
483
        // this ../d1/../d2 to ../d2
484
        source = targetLink.getParent().relativize(targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS));
14✔
485
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
486
      } else { // !relative
487
        try {
488
          source = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
11✔
489
        } catch (IOException e) {
×
490
          throw new IOException("Calling toRealPath() on " + targetLink + ".resolveSibling(" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
491
        }
1✔
492
      }
493
    }
494
    return source;
2✔
495
  }
496

497
  /**
498
   * Creates a Windows junction at {@code targetLink} pointing to {@code source}.
499
   *
500
   * @param source must be another Windows junction or a directory.
501
   * @param targetLink the location of the Windows junction.
502
   */
503
  private void createWindowsJunction(Path source, Path targetLink) {
504

505
    this.context.trace("Creating a Windows junction at " + targetLink + " with " + source + " as source.");
×
506
    Path fallbackPath;
507
    if (!source.isAbsolute()) {
×
508
      this.context.warning("You are on Windows and you do not have permissions to create symbolic links. Junctions are used as an "
×
509
          + "alternative, however, these can not point to relative paths. So the source (" + source + ") is interpreted as an absolute path.");
510
      try {
511
        fallbackPath = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
×
512
      } catch (IOException e) {
×
513
        throw new IllegalStateException(
×
514
            "Since Windows junctions are used, the source must be an absolute path. The transformation of the passed " + "source (" + source
515
                + ") to an absolute path failed.", e);
516
      }
×
517

518
    } else {
519
      fallbackPath = source;
×
520
    }
521

522
    if (!Files.isDirectory(fallbackPath)) { // if source is a junction. This returns true as well.
×
523
      this.context.newProcess().executable("cmd").addArgs("/c", "mklink", "/d", targetLink.toString(), fallbackPath.toString()).run();
×
524
    } else {
525
      this.context.newProcess().executable("cmd").addArgs("/c", "mklink", "/d", "/j", targetLink.toString(), fallbackPath.toString()).run();
×
526
    }
527
  }
×
528

529
  @Override
530
  public void symlink(Path source, Path targetLink, boolean relative) {
531

532
    Path adaptedSource = null;
2✔
533
    try {
534
      adaptedSource = adaptPath(source, targetLink, relative);
6✔
535
    } catch (IOException e) {
×
536
      throw new IllegalStateException("Failed to adapt source for source (" + source + ") target (" + targetLink + ") and relative (" + relative + ")", e);
×
537
    }
1✔
538
    this.context.debug("Creating {} symbolic link {} pointing to {}", adaptedSource.isAbsolute() ? "" : "relative", targetLink, adaptedSource);
23✔
539

540
    try {
541
      deleteLinkIfExists(targetLink);
3✔
542
    } catch (IOException e) {
×
543
      throw new IllegalStateException("Failed to delete previous symlink or Windows junction at " + targetLink, e);
×
544
    }
1✔
545

546
    try {
547
      Files.createSymbolicLink(targetLink, adaptedSource);
6✔
548
    } catch (FileSystemException e) {
×
549
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
550
        this.context.info("Due to lack of permissions, Microsoft's mklink with junction had to be used to create "
×
551
            + "a Symlink. See https://github.com/devonfw/IDEasy/blob/main/documentation/symlink.adoc for " + "further details. Error was: "
552
            + e.getMessage());
×
553
        createWindowsJunction(adaptedSource, targetLink);
×
554
      } else {
555
        throw new RuntimeException(e);
×
556
      }
557
    } catch (IOException e) {
×
558
      throw new IllegalStateException(
×
559
          "Failed to create a " + (adaptedSource.isAbsolute() ? "" : "relative") + "symbolic link " + targetLink + " pointing to " + source, e);
×
560
    }
1✔
561
  }
1✔
562

563
  @Override
564
  public Path toRealPath(Path path) {
565

566
    return toRealPath(path, true);
5✔
567
  }
568

569
  @Override
570
  public Path toCanonicalPath(Path path) {
571

572
    return toRealPath(path, false);
5✔
573
  }
574

575
  private Path toRealPath(Path path, boolean resolveLinks) {
576

577
    try {
578
      Path realPath;
579
      if (resolveLinks) {
2✔
580
        realPath = path.toRealPath();
6✔
581
      } else {
582
        realPath = path.toRealPath(LinkOption.NOFOLLOW_LINKS);
9✔
583
      }
584
      if (!realPath.equals(path)) {
4✔
585
        this.context.trace("Resolved path {} to {}", path, realPath);
14✔
586
      }
587
      return realPath;
2✔
588
    } catch (IOException e) {
×
589
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
590
    }
591
  }
592

593
  @Override
594
  public Path createTempDir(String name) {
595

596
    try {
597
      Path tmp = this.context.getTempPath();
4✔
598
      Path tempDir = tmp.resolve(name);
4✔
599
      int tries = 1;
2✔
600
      while (Files.exists(tempDir)) {
5!
601
        long id = System.nanoTime() & 0xFFFF;
×
602
        tempDir = tmp.resolve(name + "-" + id);
×
603
        tries++;
×
604
        if (tries > 200) {
×
605
          throw new IOException("Unable to create unique name!");
×
606
        }
607
      }
×
608
      return Files.createDirectory(tempDir);
5✔
609
    } catch (IOException e) {
×
610
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
611
    }
612
  }
613

614
  @Override
615
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
616

617
    if (Files.isDirectory(archiveFile)) {
5✔
618
      this.context.warning("Found directory for download at {} hence copying without extraction!", archiveFile);
10✔
619
      copy(archiveFile, targetDir, FileCopyMode.COPY_TREE_CONTENT);
5✔
620
      postExtractHook(postExtractHook, targetDir);
4✔
621
      return;
1✔
622
    } else if (!extract) {
2✔
623
      mkdirs(targetDir);
3✔
624
      move(archiveFile, targetDir.resolve(archiveFile.getFileName()));
9✔
625
      return;
1✔
626
    }
627
    Path tmpDir = createTempDir("extract-" + archiveFile.getFileName());
7✔
628
    this.context.trace("Trying to extract the downloaded file {} to {} and move it to {}.", archiveFile, tmpDir, targetDir);
18✔
629
    String filename = archiveFile.getFileName().toString();
4✔
630
    TarCompression tarCompression = TarCompression.of(filename);
3✔
631
    if (tarCompression != null) {
2✔
632
      extractTar(archiveFile, tmpDir, tarCompression);
6✔
633
    } else {
634
      String extension = FilenameUtil.getExtension(filename);
3✔
635
      if (extension == null) {
2!
636
        throw new IllegalStateException("Unknown archive format without extension - can not extract " + archiveFile);
×
637
      } else {
638
        this.context.trace("Determined file extension {}", extension);
10✔
639
      }
640
      switch (extension) {
8!
641
        case "zip" -> extractZip(archiveFile, tmpDir);
×
642
        case "jar" -> extractJar(archiveFile, tmpDir);
5✔
643
        case "dmg" -> extractDmg(archiveFile, tmpDir);
×
644
        case "msi" -> extractMsi(archiveFile, tmpDir);
×
645
        case "pkg" -> extractPkg(archiveFile, tmpDir);
×
646
        default -> throw new IllegalStateException("Unknown archive format " + extension + ". Can not extract " + archiveFile);
×
647
      }
648
    }
649
    Path properInstallDir = getProperInstallationSubDirOf(tmpDir, archiveFile);
5✔
650
    postExtractHook(postExtractHook, properInstallDir);
4✔
651
    move(properInstallDir, targetDir);
6✔
652
    delete(tmpDir);
3✔
653
  }
1✔
654

655
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
656

657
    if (postExtractHook != null) {
2✔
658
      postExtractHook.accept(properInstallDir);
3✔
659
    }
660
  }
1✔
661

662
  /**
663
   * @param path the {@link Path} to start the recursive search from.
664
   * @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
665
   *     item in their respective directory and {@code s} is not named "bin".
666
   */
667
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
668

669
    try (Stream<Path> stream = Files.list(path)) {
3✔
670
      Path[] subFiles = stream.toArray(Path[]::new);
8✔
671
      if (subFiles.length == 0) {
3!
672
        throw new CliException("The downloaded package " + archiveFile + " seems to be empty as you can check in the extracted folder " + path);
×
673
      } else if (subFiles.length == 1) {
4✔
674
        String filename = subFiles[0].getFileName().toString();
6✔
675
        if (!filename.equals(IdeContext.FOLDER_BIN) && !filename.equals(IdeContext.FOLDER_CONTENTS) && !filename.endsWith(".app") && Files.isDirectory(
4!
676
            subFiles[0])) {
677
          return getProperInstallationSubDirOf(subFiles[0], archiveFile);
×
678
        }
679
      }
680
      return path;
4✔
681
    } catch (IOException e) {
×
682
      throw new IllegalStateException("Failed to get sub-files of " + path);
×
683
    }
684
  }
685

686
  @Override
687
  public void extractZip(Path file, Path targetDir) {
688

689
    this.context.info("Extracting ZIP file {} to {}", file, targetDir);
14✔
690
    URI uri = URI.create("jar:" + file.toUri());
6✔
691
    try (FileSystem fs = FileSystems.newFileSystem(uri, FS_ENV)) {
4✔
692
      long size = 0;
2✔
693
      for (Path root : fs.getRootDirectories()) {
11✔
694
        size += getFileSizeRecursive(root);
6✔
695
      }
1✔
696
      try (final IdeProgressBar progressBar = this.context.newProgressbarForExtracting(size)) {
5✔
697
        for (Path root : fs.getRootDirectories()) {
11✔
698
          copy(root, targetDir, FileCopyMode.EXTRACT, (s, t, d) -> onFileCopiedFromZip(s, t, d, progressBar));
15✔
699
        }
1✔
700
      }
701
    } catch (IOException e) {
×
702
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
703
    }
1✔
704
  }
1✔
705

706
  @SuppressWarnings("unchecked")
707
  private void onFileCopiedFromZip(Path source, Path target, boolean directory, IdeProgressBar progressBar) {
708

709
    if (directory) {
2✔
710
      return;
1✔
711
    }
712
    if (!context.getSystemInfo().isWindows()) {
5✔
713
      try {
714
        Object attribute = Files.getAttribute(source, "zip:permissions");
6✔
715
        if (attribute instanceof Set<?> permissionSet) {
6✔
716
          Files.setPosixFilePermissions(target, (Set<PosixFilePermission>) permissionSet);
4✔
717
        }
718
      } catch (Exception e) {
×
719
        context.error(e, "Failed to transfer zip permissions for {}", target);
×
720
      }
1✔
721
    }
722
    progressBar.stepBy(getFileSize(target));
5✔
723
  }
1✔
724

725
  @Override
726
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
727

728
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
729
  }
1✔
730

731
  @Override
732
  public void extractJar(Path file, Path targetDir) {
733

734
    extractZip(file, targetDir);
4✔
735
  }
1✔
736

737
  /**
738
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
739
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
740
   */
741
  public static String generatePermissionString(int permissions) {
742

743
    // Ensure that only the last 9 bits are considered
744
    permissions &= 0b111111111;
4✔
745

746
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
747
    for (int i = 0; i < 9; i++) {
7✔
748
      int mask = 1 << i;
4✔
749
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
750
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
751
    }
752

753
    return permissionStringBuilder.toString();
3✔
754
  }
755

756
  private void extractArchive(
757
      Path file,
758
      Path targetDir,
759
      java.util.function.Function<InputStream, ArchiveInputStream<?>> unpacker
760
  ) {
761
    this.context.info("Extracting TAR file {} to {}", file, targetDir);
14✔
762

763
    class HardLink {
764

765
      final Path linkPath;     // where to create the hard link
766
      final String targetName; // tar-stored name of the target
767

768
      HardLink(Path linkPath, String targetName) {
×
769
        this.linkPath = linkPath;
×
770
        this.targetName = targetName;
×
771
      }
×
772
    }
773
    class PendingSymlink {
774

775
      final Path linkPath;
776
      final String linkName; // textual target from tar (may be relative)
777

778
      PendingSymlink(Path linkPath, String linkName) {
×
779
        this.linkPath = linkPath;
×
780
        this.linkName = linkName;
×
781
      }
×
782
    }
783

784
    final List<HardLink> hardLinks = new ArrayList<>();
4✔
785
    final List<PendingSymlink> pendingSymlinkFallbacks = new ArrayList<>();
4✔
786

787
    try (InputStream is = Files.newInputStream(file);
5✔
788
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
789
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
790

791
      final boolean isTar = (ais instanceof TarArchiveInputStream);
3✔
792
      final boolean isWindows = this.context.getSystemInfo().isWindows();
5✔
793
      final Path root = targetDir.toAbsolutePath().normalize();
4✔
794

795
      ArchiveEntry entry = ais.getNextEntry();
3✔
796
      while (entry != null) {
2✔
797
        final String permissionStr;
798
        final boolean isSymlink;
799
        final boolean isHardlink;
800
        final String linkName;
801

802
        if (isTar) {
2!
803
          TarArchiveEntry te = (TarArchiveEntry) entry;
3✔
804
          permissionStr = generatePermissionString(te.getMode());
4✔
805
          isSymlink = te.isSymbolicLink();
3✔
806
          isHardlink = te.isLink();
3✔
807
          linkName = te.getLinkName();
3✔
808
        } else {
1✔
809
          permissionStr = null;
×
810
          isSymlink = false;
×
811
          isHardlink = false;
×
812
          linkName = null;
×
813
        }
814

815
        Path entryName = Path.of(entry.getName());
6✔
816
        Path entryPath = root.resolve(entryName).normalize();
5✔
817
        if (!entryPath.startsWith(root)) {
4!
818
          throw new IOException("Preventing path traversal attack from " + entryName + " to " + entryPath);
×
819
        }
820

821
        if (entry.isDirectory()) {
3✔
822
          mkdirs(entryPath);
4✔
823

824
        } else if (isTar && isSymlink) {
4!
825
          // --- Symbolic link ---
826
          mkdirs(entryPath.getParent());
×
827
          Path linkTargetText = Paths.get(linkName); // as recorded in TAR
×
828

829
          try {
830
            Files.deleteIfExists(entryPath);
×
831
            Files.createSymbolicLink(entryPath, linkTargetText);
×
832
          } catch (FileSystemException e) {
×
833
            // Likely no symlink privilege on Windows: handle in post-pass.
834
            pendingSymlinkFallbacks.add(new PendingSymlink(entryPath, linkName));
×
835
          }
×
836

837
        } else if (isTar && isHardlink) {
4!
838
          // --- Hard link (defer until target exists) ---
839
          hardLinks.add(new HardLink(entryPath, linkName));
×
840

841
        } else {
842
          // --- Regular file ---
843
          mkdirs(entryPath.getParent());
4✔
844
          Files.copy(ais, entryPath, StandardCopyOption.REPLACE_EXISTING);
10✔
845

846
          // POSIX perms on non-Windows
847
          if (isTar && !isWindows && permissionStr != null) {
6!
848
            try {
849
              Set<PosixFilePermission> permissions = PosixFilePermissions.fromString(permissionStr);
3✔
850
              Files.setPosixFilePermissions(entryPath, permissions);
4✔
851
            } catch (UnsupportedOperationException ignored) { /* FS has no posix view */ }
1✔
852
          }
853
        }
854

855
        pb.stepBy(Math.max(0L, entry.getSize()));
6✔
856
        entry = ais.getNextEntry();
3✔
857
      }
1✔
858

859
      // --- Post-pass: hard links ---
860
      for (HardLink hl : hardLinks) {
6!
861
        Path targetInExtract = root.resolve(Path.of(hl.targetName)).normalize();
×
862
        if (!targetInExtract.startsWith(root)) {
×
863
          throw new IOException("Hard link target escapes extraction root: " + hl.targetName);
×
864
        }
865
        if (!Files.exists(targetInExtract)) {
×
866
          throw new IOException("Hard link target does not exist: " + hl.targetName);
×
867
        }
868
        mkdirs(hl.linkPath.getParent());
×
869
        Files.deleteIfExists(hl.linkPath);
×
870
        Files.createLink(hl.linkPath, targetInExtract);
×
871
      }
×
872

873
      // --- Post-pass: Windows symlink fallback ---
874
      if (!pendingSymlinkFallbacks.isEmpty() && isWindows) {
3!
875
        for (PendingSymlink ps : pendingSymlinkFallbacks) {
×
876
          Path linkPath = ps.linkPath;
×
877
          Path linkTargetText = Paths.get(ps.linkName);
×
878
          // POSIX semantics: relative link targets resolve against link's parent
879
          Path resolvedTarget = linkPath.getParent().resolve(linkTargetText).normalize();
×
880

881
          // Only allow targets inside extraction root
882
          if (!resolvedTarget.startsWith(root)) {
×
883
            this.context.info("Skipping symlink fallback outside extraction root: {} -> {}", linkPath, ps.linkName);
×
884
            continue;
×
885
          }
886
          if (!Files.exists(resolvedTarget)) {
×
887
            this.context.info("Skipping symlink fallback; missing target: {} -> {}", linkPath, ps.linkName);
×
888
            continue;
×
889
          }
890
          createWindowsJunction(resolvedTarget, linkPath);
×
891
        }
×
892
      }
893

894
    } catch (IOException e) {
×
895
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
896
    }
1✔
897
  }
1✔
898

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

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

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

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

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

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

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

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

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

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

958
  @Override
959
  public void compressTar(Path dir, OutputStream out, TarCompression tarCompression) {
960
    switch (tarCompression) {
8!
961
      case null -> compressTar(dir, out);
×
962
      case NONE -> compressTar(dir, out);
×
963
      case GZ -> compressTarGz(dir, out);
5✔
964
      case BZIP2 -> compressTarBzip2(dir, out);
×
965
      default -> throw new IllegalArgumentException("Unsupported tar compression: " + tarCompression);
×
966
    }
967
  }
1✔
968

969
  @Override
970
  public void compressTarGz(Path dir, OutputStream out) {
971
    try (GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(out)) {
5✔
972
      compressTarOrThrow(dir, gzOut);
4✔
973
    } catch (IOException e) {
×
974
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.gz file.", e);
×
975
    }
1✔
976
  }
1✔
977

978
  @Override
979
  public void compressTarBzip2(Path dir, OutputStream out) {
980
    try (BZip2CompressorOutputStream bzip2Out = new BZip2CompressorOutputStream(out)) {
×
981
      compressTarOrThrow(dir, bzip2Out);
×
982
    } catch (IOException e) {
×
983
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar.bz2 file.", e);
×
984
    }
×
985
  }
×
986

987
  @Override
988
  public void compressTar(Path dir, OutputStream out) {
989
    try {
990
      compressTarOrThrow(dir, out);
×
991
    } catch (IOException e) {
×
992
      throw new IllegalStateException("Failed to compress directory " + dir + " to tar file.", e);
×
993
    }
×
994
  }
×
995

996
  private void compressTarOrThrow(Path dir, OutputStream out) throws IOException {
997
    try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) {
5✔
998
      compressRecursive(dir, tarOut, "");
5✔
999
      tarOut.finish();
2✔
1000
    }
1001
  }
1✔
1002

1003
  @Override
1004
  public void compressZip(Path dir, OutputStream out) {
1005
    try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(out)) {
×
1006
      compressRecursive(dir, zipOut, "");
×
1007
      zipOut.finish();
×
1008
    } catch (IOException e) {
×
1009
      throw new IllegalStateException("Failed to compress directory " + dir + " to zip file.", e);
×
1010
    }
×
1011
  }
×
1012

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

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

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

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

1078
    if (Files.isDirectory(path)) {
5✔
1079
      try (Stream<Path> childStream = Files.list(path)) {
3✔
1080
        Iterator<Path> iterator = childStream.iterator();
3✔
1081
        while (iterator.hasNext()) {
3✔
1082
          Path child = iterator.next();
4✔
1083
          deleteRecursive(child);
3✔
1084
        }
1✔
1085
      }
1086
    }
1087
    this.context.trace("Deleting {} ...", path);
10✔
1088
    boolean isSetWritable = setWritable(path, true);
5✔
1089
    if (!isSetWritable) {
2✔
1090
      this.context.debug("Couldn't give write access to file: " + path);
6✔
1091
    }
1092
    Files.delete(path);
2✔
1093
  }
1✔
1094

1095
  @Override
1096
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
1097

1098
    try {
1099
      if (!Files.isDirectory(dir)) {
5✔
1100
        return null;
2✔
1101
      }
1102
      return findFirstRecursive(dir, filter, recursive);
6✔
1103
    } catch (IOException e) {
×
1104
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
1105
    }
1106
  }
1107

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

1110
    List<Path> folders = null;
2✔
1111
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1112
      Iterator<Path> iterator = childStream.iterator();
3✔
1113
      while (iterator.hasNext()) {
3✔
1114
        Path child = iterator.next();
4✔
1115
        if (filter.test(child)) {
4✔
1116
          return child;
4✔
1117
        } else if (recursive && Files.isDirectory(child)) {
2!
1118
          if (folders == null) {
×
1119
            folders = new ArrayList<>();
×
1120
          }
1121
          folders.add(child);
×
1122
        }
1123
      }
1✔
1124
    }
4!
1125
    if (folders != null) {
2!
1126
      for (Path child : folders) {
×
1127
        Path match = findFirstRecursive(child, filter, recursive);
×
1128
        if (match != null) {
×
1129
          return match;
×
1130
        }
1131
      }
×
1132
    }
1133
    return null;
2✔
1134
  }
1135

1136
  @Override
1137
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
1138

1139
    if (!Files.isDirectory(dir)) {
5✔
1140
      return List.of();
2✔
1141
    }
1142
    List<Path> children = new ArrayList<>();
4✔
1143
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
1144
      Iterator<Path> iterator = childStream.iterator();
3✔
1145
      while (iterator.hasNext()) {
3✔
1146
        Path child = iterator.next();
4✔
1147
        Path filteredChild = filter.apply(child);
5✔
1148
        if (filteredChild != null) {
2✔
1149
          if (filteredChild == child) {
3!
1150
            this.context.trace("Accepted file {}", child);
11✔
1151
          } else {
1152
            this.context.trace("Accepted file {} and mapped to {}", child, filteredChild);
×
1153
          }
1154
          children.add(filteredChild);
5✔
1155
        } else {
1156
          this.context.trace("Ignoring file {} according to filter", child);
10✔
1157
        }
1158
      }
1✔
1159
    } catch (IOException e) {
×
1160
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
1161
    }
1✔
1162
    return children;
2✔
1163
  }
1164

1165
  @Override
1166
  public boolean isEmptyDir(Path dir) {
1167

1168
    return listChildren(dir, f -> true).isEmpty();
8✔
1169
  }
1170

1171
  private long getFileSize(Path file) {
1172

1173
    try {
1174
      return Files.size(file);
3✔
1175
    } catch (IOException e) {
×
1176
      this.context.warning(e.getMessage(), e);
×
1177
      return 0;
×
1178
    }
1179
  }
1180

1181
  private long getFileSizeRecursive(Path path) {
1182

1183
    long size = 0;
2✔
1184
    if (Files.isDirectory(path)) {
5✔
1185
      try (Stream<Path> childStream = Files.list(path)) {
3✔
1186
        Iterator<Path> iterator = childStream.iterator();
3✔
1187
        while (iterator.hasNext()) {
3✔
1188
          Path child = iterator.next();
4✔
1189
          size += getFileSizeRecursive(child);
6✔
1190
        }
1✔
1191
      } catch (IOException e) {
×
1192
        throw new RuntimeException("Failed to iterate children of folder " + path, e);
×
1193
      }
1✔
1194
    } else {
1195
      size += getFileSize(path);
6✔
1196
    }
1197
    return size;
2✔
1198
  }
1199

1200
  @Override
1201
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
1202

1203
    for (Path dir : searchDirs) {
10!
1204
      Path filePath = dir.resolve(fileName);
4✔
1205
      try {
1206
        if (Files.exists(filePath)) {
5✔
1207
          return filePath;
2✔
1208
        }
1209
      } catch (Exception e) {
×
1210
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
1211
      }
1✔
1212
    }
1✔
1213
    return null;
×
1214
  }
1215

1216
  @Override
1217
  public boolean setWritable(Path file, boolean writable) {
1218
    try {
1219
      // POSIX
1220
      PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class);
7✔
1221
      if (posix != null) {
2!
1222
        Set<PosixFilePermission> permissions = new HashSet<>(posix.readAttributes().permissions());
7✔
1223
        boolean changed;
1224
        if (writable) {
2!
1225
          changed = permissions.add(PosixFilePermission.OWNER_WRITE);
5✔
1226
        } else {
1227
          changed = permissions.remove(PosixFilePermission.OWNER_WRITE);
×
1228
        }
1229
        if (changed) {
2!
1230
          posix.setPermissions(permissions);
×
1231
        }
1232
        return true;
2✔
1233
      }
1234

1235
      // Windows
1236
      DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class);
×
1237
      if (dos != null) {
×
1238
        dos.setReadOnly(!writable);
×
1239
        return true;
×
1240
      }
1241

1242
      this.context.debug("Failed to set writing permission for file {}", file);
×
1243
      return false;
×
1244

1245
    } catch (IOException e) {
1✔
1246
      this.context.debug("Error occurred when trying to set writing permission for file " + file + ": " + e);
8✔
1247
      return false;
2✔
1248
    }
1249
  }
1250

1251
  @Override
1252
  public void makeExecutable(Path file, boolean confirm) {
1253

1254
    if (Files.exists(file)) {
5✔
1255
      if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1256
        this.context.trace("Windows does not have executable flags hence omitting for file {}", file);
×
1257
        return;
×
1258
      }
1259
      try {
1260
        // Read the current file permissions
1261
        Set<PosixFilePermission> existingPermissions = Files.getPosixFilePermissions(file);
5✔
1262

1263
        // Add execute permission for all users
1264
        Set<PosixFilePermission> executablePermissions = new HashSet<>(existingPermissions);
5✔
1265
        boolean update = false;
2✔
1266
        update |= executablePermissions.add(PosixFilePermission.OWNER_EXECUTE);
6✔
1267
        update |= executablePermissions.add(PosixFilePermission.GROUP_EXECUTE);
6✔
1268
        update |= executablePermissions.add(PosixFilePermission.OTHERS_EXECUTE);
6✔
1269

1270
        if (update) {
2✔
1271
          if (confirm) {
2!
1272
            boolean yesContinue = this.context.question(
×
1273
                "We want to execute " + file.getFileName() + " but this command seems to lack executable permissions!\n"
×
1274
                    + "Most probably the tool vendor did forgot to add x-flags in the binary release package.\n"
1275
                    + "Before running the command, we suggest to set executable permissions to the file:\n"
1276
                    + file + "\n"
1277
                    + "For security reasons we ask for your confirmation so please check this request.\n"
1278
                    + "Changing permissions from " + PosixFilePermissions.toString(existingPermissions) + " to " + PosixFilePermissions.toString(
×
1279
                    executablePermissions) + ".\n"
1280
                    + "Do you confirm to make the command executable before running it?");
1281
            if (!yesContinue) {
×
1282
              return;
×
1283
            }
1284
          }
1285
          this.context.debug("Setting executable flags for file {}", file);
10✔
1286
          // Set the new permissions
1287
          Files.setPosixFilePermissions(file, executablePermissions);
5✔
1288
        } else {
1289
          this.context.trace("Executable flags already present so no need to set them for file {}", file);
10✔
1290
        }
1291
      } catch (IOException e) {
×
1292
        throw new RuntimeException(e);
×
1293
      }
1✔
1294
    } else {
1295
      this.context.warning("Cannot set executable flag on file that does not exist: {}", file);
10✔
1296
    }
1297
  }
1✔
1298

1299
  @Override
1300
  public void touch(Path file) {
1301

1302
    if (Files.exists(file)) {
5✔
1303
      try {
1304
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
1305
      } catch (IOException e) {
×
1306
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
1307
      }
1✔
1308
    } else {
1309
      try {
1310
        Files.createFile(file);
5✔
1311
      } catch (IOException e) {
1✔
1312
        throw new IllegalStateException("Could not create empty file " + file, e);
8✔
1313
      }
1✔
1314
    }
1315
  }
1✔
1316

1317
  @Override
1318
  public String readFileContent(Path file) {
1319

1320
    this.context.trace("Reading content of file from {}", file);
10✔
1321
    if (!Files.exists((file))) {
5!
1322
      this.context.debug("File {} does not exist", file);
×
1323
      return null;
×
1324
    }
1325
    try {
1326
      String content = Files.readString(file);
3✔
1327
      this.context.trace("Completed reading {} character(s) from file {}", content.length(), file);
16✔
1328
      return content;
2✔
1329
    } catch (IOException e) {
×
1330
      throw new IllegalStateException("Failed to read file " + file, e);
×
1331
    }
1332
  }
1333

1334
  @Override
1335
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1336

1337
    if (createParentDir) {
2✔
1338
      mkdirs(file.getParent());
4✔
1339
    }
1340
    if (content == null) {
2!
1341
      content = "";
×
1342
    }
1343
    this.context.trace("Writing content with {} character(s) to file {}", content.length(), file);
16✔
1344
    if (Files.exists(file)) {
5✔
1345
      this.context.info("Overriding content of file {}", file);
10✔
1346
    }
1347
    try {
1348
      Files.writeString(file, content);
6✔
1349
      this.context.trace("Wrote content to file {}", file);
10✔
1350
    } catch (IOException e) {
×
1351
      throw new RuntimeException("Failed to write file " + file, e);
×
1352
    }
1✔
1353
  }
1✔
1354

1355
  @Override
1356
  public List<String> readFileLines(Path file) {
1357

1358
    this.context.trace("Reading content of file from {}", file);
10✔
1359
    if (!Files.exists(file)) {
5✔
1360
      this.context.warning("File {} does not exist", file);
10✔
1361
      return null;
2✔
1362
    }
1363
    try {
1364
      List<String> content = Files.readAllLines(file);
3✔
1365
      this.context.trace("Completed reading {} lines from file {}", content.size(), file);
16✔
1366
      return content;
2✔
1367
    } catch (IOException e) {
×
1368
      throw new IllegalStateException("Failed to read file " + file, e);
×
1369
    }
1370
  }
1371

1372
  @Override
1373
  public void writeFileLines(List<String> content, Path file, boolean createParentDir) {
1374

1375
    if (createParentDir) {
2!
1376
      mkdirs(file.getParent());
×
1377
    }
1378
    if (content == null) {
2!
1379
      content = List.of();
×
1380
    }
1381
    this.context.trace("Writing content with {} lines to file {}", content.size(), file);
16✔
1382
    if (Files.exists(file)) {
5✔
1383
      this.context.debug("Overriding content of file {}", file);
10✔
1384
    }
1385
    try {
1386
      Files.write(file, content);
6✔
1387
      this.context.trace("Wrote content to file {}", file);
10✔
1388
    } catch (IOException e) {
×
1389
      throw new RuntimeException("Failed to write file " + file, e);
×
1390
    }
1✔
1391
  }
1✔
1392

1393
  @Override
1394
  public void readProperties(Path file, Properties properties) {
1395

1396
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1397
      properties.load(reader);
3✔
1398
      this.context.debug("Successfully loaded {} properties from {}", properties.size(), file);
16✔
1399
    } catch (IOException e) {
×
1400
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1401
    }
1✔
1402
  }
1✔
1403

1404
  @Override
1405
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1406

1407
    if (createParentDir) {
2✔
1408
      mkdirs(file.getParent());
4✔
1409
    }
1410
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1411
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1412
      this.context.debug("Successfully saved {} properties to {}", properties.size(), file);
16✔
1413
    } catch (IOException e) {
×
1414
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1415
    }
1✔
1416
  }
1✔
1417

1418
  @Override
1419
  public void readIniFile(Path file, IniFile iniFile) {
1420
    if (!Files.exists(file)) {
5!
1421
      this.context.debug("INI file {} does not exist.", iniFile);
×
1422
      return;
×
1423
    }
1424
    List<String> iniLines = readFileLines(file);
4✔
1425
    IniSection currentIniSection = iniFile.getInitialSection();
3✔
1426
    for (String line : iniLines) {
10✔
1427
      if (line.trim().startsWith("[")) {
5✔
1428
        currentIniSection = iniFile.getOrCreateSection(line);
5✔
1429
      } else if (line.isBlank() || IniComment.COMMENT_SYMBOLS.contains(line.trim().charAt(0))) {
11✔
1430
        currentIniSection.addComment(line);
4✔
1431
      } else {
1432
        int index = line.indexOf('=');
4✔
1433
        if (index > 0) {
2!
1434
          currentIniSection.setProperty(line);
3✔
1435
        }
1436
      }
1437
    }
1✔
1438
  }
1✔
1439

1440
  @Override
1441
  public void writeIniFile(IniFile iniFile, Path file, boolean createParentDir) {
1442
    String iniString = iniFile.toString();
3✔
1443
    writeFileContent(iniString, file, createParentDir);
5✔
1444
  }
1✔
1445

1446
  @Override
1447
  public Duration getFileAge(Path path) {
1448
    if (Files.exists(path)) {
5✔
1449
      try {
1450
        long currentTime = System.currentTimeMillis();
2✔
1451
        long fileModifiedTime = Files.getLastModifiedTime(path).toMillis();
6✔
1452
        return Duration.ofMillis(currentTime - fileModifiedTime);
5✔
1453
      } catch (IOException e) {
×
1454
        this.context.warning().log(e, "Could not get modification-time of {}.", path);
×
1455
      }
×
1456
    } else {
1457
      this.context.debug("Path {} is missing - skipping modification-time and file age check.", path);
10✔
1458
    }
1459
    return null;
2✔
1460
  }
1461

1462
  @Override
1463
  public boolean isFileAgeRecent(Path path, Duration cacheDuration) {
1464

1465
    Duration age = getFileAge(path);
4✔
1466
    if (age == null) {
2✔
1467
      return false;
2✔
1468
    }
1469
    context.debug("The path {} was last updated {} ago and caching duration is {}.", path, age, cacheDuration);
18✔
1470
    return (age.toMillis() <= cacheDuration.toMillis());
10✔
1471
  }
1472
}
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