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

devonfw / IDEasy / 16392027996

19 Jul 2025 07:31PM UTC coverage: 68.396% (-0.05%) from 68.446%
16392027996

Pull #1419

github

web-flow
Merge 299ff4584 into ab7eb024e
Pull Request #1419: Fix Windows junction creation failure when broken junction exists

3293 of 5216 branches covered (63.13%)

Branch coverage included in aggregate %.

8413 of 11899 relevant lines covered (70.7%)

3.12 hits per line

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

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

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

47
import org.apache.commons.compress.archivers.ArchiveEntry;
48
import org.apache.commons.compress.archivers.ArchiveInputStream;
49
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
50
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
51
import org.jline.utils.Log;
52

53
import com.devonfw.tools.ide.cli.CliException;
54
import com.devonfw.tools.ide.cli.CliOfflineException;
55
import com.devonfw.tools.ide.context.IdeContext;
56
import com.devonfw.tools.ide.os.SystemInfoImpl;
57
import com.devonfw.tools.ide.process.ProcessContext;
58
import com.devonfw.tools.ide.util.DateTimeUtil;
59
import com.devonfw.tools.ide.util.FilenameUtil;
60
import com.devonfw.tools.ide.util.HexUtil;
61

62
/**
63
 * Implementation of {@link FileAccess}.
64
 */
65
public class FileAccessImpl implements FileAccess {
66

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

69
  private static final String WINDOWS_FILE_LOCK_WARNING =
70
      "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"
71
          + WINDOWS_FILE_LOCK_DOCUMENTATION_PAGE;
72

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

75
  private final IdeContext context;
76

77
  /**
78
   * The constructor.
79
   *
80
   * @param context the {@link IdeContext} to use.
81
   */
82
  public FileAccessImpl(IdeContext context) {
83

84
    super();
2✔
85
    this.context = context;
3✔
86
  }
1✔
87

88
  private HttpClient createHttpClient(String url) {
89

90
    HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS);
4✔
91
    return builder.build();
3✔
92
  }
93

94
  @Override
95
  public void download(String url, Path target) {
96

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

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

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

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

140
    long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1);
7✔
141
    informAboutMissingContentLength(contentLength, url);
4✔
142

143
    byte[] data = new byte[1024];
3✔
144
    boolean fileComplete = false;
2✔
145
    int count;
146

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

161
    } catch (Exception e) {
×
162
      throw new RuntimeException(e);
×
163
    }
1✔
164
  }
1✔
165

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

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

191
  private void informAboutMissingContentLength(long contentLength, String url) {
192

193
    if (contentLength < 0) {
4✔
194
      this.context.warning("Content-Length was not provided by download from {}", url);
10✔
195
    }
196
  }
1✔
197

198
  @Override
199
  public void mkdirs(Path directory) {
200

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

212
  @Override
213
  public boolean isFile(Path file) {
214

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

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

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

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

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

258
  public boolean isJunction(Path path) {
259

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

264
    try {
265
      BasicFileAttributes attr = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
×
266
      return attr.isOther() && attr.isDirectory();
×
267
    } catch (NoSuchFileException e) {
×
268
      return false; // file doesn't exist
×
269
    } catch (IOException e) {
×
270
      // For broken junctions, reading attributes might fail with various IOExceptions.
271
      // In such cases, we should check if the path exists without following links.
272
      // If it exists but we can't read its attributes, it's likely a broken junction.
273
      if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
×
274
        this.context.debug("Path " + path + " exists but attributes cannot be read, likely a broken junction: " + e.getMessage());
×
275
        return true; // Assume it's a broken junction
×
276
      }
277
      // If it doesn't exist at all, it's not a junction
278
      return false;
×
279
    }
280
  }
281

282
  @Override
283
  public Path backup(Path fileOrFolder) {
284

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

305
  private static Path appendParentPath(Path path, Path parent, int max) {
306

307
    if ((parent == null) || (max <= 0)) {
4!
308
      return path;
2✔
309
    }
310
    return appendParentPath(path, parent.getParent(), max - 1).resolve(parent.getFileName());
11✔
311
  }
312

313
  @Override
314
  public void move(Path source, Path targetDir, StandardCopyOption... copyOptions) {
315

316
    this.context.trace("Moving {} to {}", source, targetDir);
14✔
317
    try {
318
      Files.move(source, targetDir, copyOptions);
5✔
319
    } catch (IOException e) {
×
320
      String fileType = Files.isSymbolicLink(source) ? "symlink" : isJunction(source) ? "junction" : Files.isDirectory(source) ? "directory" : "file";
×
321
      String message = "Failed to move " + fileType + ": " + source + " to " + targetDir + ".";
×
322
      if (this.context.getSystemInfo().isWindows()) {
×
323
        message = message + "\n" + WINDOWS_FILE_LOCK_WARNING;
×
324
      }
325
      throw new IllegalStateException(message, e);
×
326
    }
1✔
327
  }
1✔
328

329
  @Override
330
  public void copy(Path source, Path target, FileCopyMode mode, PathCopyListener listener) {
331

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

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

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

398
  /**
399
   * 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
400
   * {@link Path} that is neither a symbolic link nor a Windows junction.
401
   *
402
   * @param path the {@link Path} to delete.
403
   * @throws IOException if the actual {@link Files#delete(Path) deletion} fails.
404
   */
405
  private void deleteLinkIfExists(Path path) throws IOException {
406

407
    boolean isJunction = isJunction(path); // since broken junctions are not detected by Files.exists()
4✔
408
    boolean isSymlink = Files.exists(path) && Files.isSymbolicLink(path);
12!
409
    
410
    // Also check for broken symlinks that exist but don't resolve (Files.exists() returns false for these)
411
    if (!isSymlink && Files.exists(path, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(path)) {
14!
412
      isSymlink = true; // This is a broken symlink
2✔
413
    }
414

415
    assert !(isSymlink && isJunction);
5!
416

417
    if (isJunction || isSymlink) {
4!
418
      this.context.info("Deleting previous " + (isJunction ? "junction" : "symlink") + " at " + path);
9!
419
      Files.delete(path);
3✔
420
    } else if (SystemInfoImpl.INSTANCE.isWindows() && Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
3!
421
      // On Windows, there might be broken junctions that are not detected by isJunction()
422
      // but still exist and would cause mklink to fail. Try to detect and handle them.
423
      try {
424
        BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
×
425
        if (attrs.isOther()) {
×
426
          // This could be a broken junction or other special file that needs to be removed
427
          this.context.info("Deleting previous special file (possibly broken junction) at " + path);
×
428
          Files.delete(path);
×
429
        }
430
      } catch (IOException e) {
×
431
        // If we can't read attributes, it might be a broken junction
432
        this.context.debug("Could not read attributes of " + path + ", attempting to delete as it may be a broken junction: " + e.getMessage());
×
433
        try {
434
          Files.delete(path);
×
435
          this.context.info("Successfully deleted potentially broken junction at " + path);
×
436
        } catch (IOException deleteEx) {
×
437
          this.context.debug("Failed to delete " + path + ": " + deleteEx.getMessage());
×
438
          // Re-throw the original exception if deletion fails
439
          throw e;
×
440
        }
×
441
      }
×
442
    }
443
  }
1✔
444

445
  /**
446
   * Adapts the given {@link Path} to be relative or absolute depending on the given {@code relative} flag. Additionally, {@link Path#toRealPath(LinkOption...)}
447
   * is applied to {@code source}.
448
   *
449
   * @param source the {@link Path} to adapt.
450
   * @param targetLink the {@link Path} used to calculate the relative path to the {@code source} if {@code relative} is set to {@code true}.
451
   * @param relative the {@code relative} flag.
452
   * @return the adapted {@link Path}.
453
   * @see FileAccessImpl#symlink(Path, Path, boolean)
454
   */
455
  private Path adaptPath(Path source, Path targetLink, boolean relative) throws IOException {
456

457
    if (source.isAbsolute()) {
3✔
458
      try {
459
        source = source.toRealPath(LinkOption.NOFOLLOW_LINKS); // to transform ../d1/../d2 to ../d2
9✔
460
      } catch (IOException e) {
×
461
        throw new IOException("Calling toRealPath() on the source (" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
462
      }
1✔
463
      if (relative) {
2✔
464
        source = targetLink.getParent().relativize(source);
5✔
465
        // to make relative links like this work: dir/link -> dir
466
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
467
      }
468
    } else { // source is relative
469
      if (relative) {
2✔
470
        // even though the source is already relative, toRealPath should be called to transform paths like
471
        // this ../d1/../d2 to ../d2
472
        source = targetLink.getParent().relativize(targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS));
14✔
473
        source = (source.toString().isEmpty()) ? Path.of(".") : source;
12✔
474
      } else { // !relative
475
        try {
476
          source = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
11✔
477
        } catch (IOException e) {
×
478
          throw new IOException("Calling toRealPath() on " + targetLink + ".resolveSibling(" + source + ") in method FileAccessImpl.adaptPath() failed.", e);
×
479
        }
1✔
480
      }
481
    }
482
    return source;
2✔
483
  }
484

485
  /**
486
   * Creates a Windows junction at {@code targetLink} pointing to {@code source}.
487
   *
488
   * @param source must be another Windows junction or a directory.
489
   * @param targetLink the location of the Windows junction.
490
   */
491
  private void createWindowsJunction(Path source, Path targetLink) {
492

493
    this.context.trace("Creating a Windows junction at " + targetLink + " with " + source + " as source.");
×
494
    Path fallbackPath;
495
    if (!source.isAbsolute()) {
×
496
      this.context.warning("You are on Windows and you do not have permissions to create symbolic links. Junctions are used as an "
×
497
          + "alternative, however, these can not point to relative paths. So the source (" + source + ") is interpreted as an absolute path.");
498
      try {
499
        fallbackPath = targetLink.resolveSibling(source).toRealPath(LinkOption.NOFOLLOW_LINKS);
×
500
      } catch (IOException e) {
×
501
        throw new IllegalStateException(
×
502
            "Since Windows junctions are used, the source must be an absolute path. The transformation of the passed " + "source (" + source
503
                + ") to an absolute path failed.", e);
504
      }
×
505

506
    } else {
507
      fallbackPath = source;
×
508
    }
509
    if (!Files.isDirectory(fallbackPath)) { // if source is a junction. This returns true as well.
×
510
      throw new IllegalStateException(
×
511
          "These junctions can only point to directories or other junctions. Please make sure that the source (" + fallbackPath + ") is one of these.");
512
    }
513
    this.context.newProcess().executable("cmd").addArgs("/c", "mklink", "/d", "/j", targetLink.toString(), fallbackPath.toString()).run();
×
514
  }
×
515

516
  @Override
517
  public void symlink(Path source, Path targetLink, boolean relative) {
518

519
    Path adaptedSource = null;
2✔
520
    try {
521
      adaptedSource = adaptPath(source, targetLink, relative);
6✔
522
    } catch (IOException e) {
×
523
      throw new IllegalStateException("Failed to adapt source for source (" + source + ") target (" + targetLink + ") and relative (" + relative + ")", e);
×
524
    }
1✔
525
    this.context.debug("Creating {} symbolic link {} pointing to {}", adaptedSource.isAbsolute() ? "" : "relative", targetLink, adaptedSource);
23✔
526

527
    try {
528
      deleteLinkIfExists(targetLink);
3✔
529
    } catch (IOException e) {
×
530
      throw new IllegalStateException("Failed to delete previous symlink or Windows junction at " + targetLink, e);
×
531
    }
1✔
532

533
    try {
534
      Files.createSymbolicLink(targetLink, adaptedSource);
6✔
535
    } catch (FileSystemException e) {
×
536
      if (SystemInfoImpl.INSTANCE.isWindows()) {
×
537
        this.context.info("Due to lack of permissions, Microsoft's mklink with junction had to be used to create "
×
538
            + "a Symlink. See https://github.com/devonfw/IDEasy/blob/main/documentation/symlink.adoc for " + "further details. Error was: "
539
            + e.getMessage());
×
540
        createWindowsJunction(adaptedSource, targetLink);
×
541
      } else {
542
        throw new RuntimeException(e);
×
543
      }
544
    } catch (IOException e) {
×
545
      throw new IllegalStateException(
×
546
          "Failed to create a " + (adaptedSource.isAbsolute() ? "" : "relative") + "symbolic link " + targetLink + " pointing to " + source, e);
×
547
    }
1✔
548
  }
1✔
549

550
  @Override
551
  public Path toRealPath(Path path) {
552

553
    return toRealPath(path, true);
5✔
554
  }
555

556
  @Override
557
  public Path toCanonicalPath(Path path) {
558

559
    return toRealPath(path, false);
5✔
560
  }
561

562
  private Path toRealPath(Path path, boolean resolveLinks) {
563

564
    try {
565
      Path realPath;
566
      if (resolveLinks) {
2✔
567
        realPath = path.toRealPath();
6✔
568
      } else {
569
        realPath = path.toRealPath(LinkOption.NOFOLLOW_LINKS);
9✔
570
      }
571
      if (!realPath.equals(path)) {
4✔
572
        this.context.trace("Resolved path {} to {}", path, realPath);
14✔
573
      }
574
      return realPath;
2✔
575
    } catch (IOException e) {
×
576
      throw new IllegalStateException("Failed to get real path for " + path, e);
×
577
    }
578
  }
579

580
  @Override
581
  public Path createTempDir(String name) {
582

583
    try {
584
      Path tmp = this.context.getTempPath();
4✔
585
      Path tempDir = tmp.resolve(name);
4✔
586
      int tries = 1;
2✔
587
      while (Files.exists(tempDir)) {
5!
588
        long id = System.nanoTime() & 0xFFFF;
×
589
        tempDir = tmp.resolve(name + "-" + id);
×
590
        tries++;
×
591
        if (tries > 200) {
×
592
          throw new IOException("Unable to create unique name!");
×
593
        }
594
      }
×
595
      return Files.createDirectory(tempDir);
5✔
596
    } catch (IOException e) {
×
597
      throw new IllegalStateException("Failed to create temporary directory with prefix '" + name + "'!", e);
×
598
    }
599
  }
600

601
  @Override
602
  public void extract(Path archiveFile, Path targetDir, Consumer<Path> postExtractHook, boolean extract) {
603

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

644
  private void postExtractHook(Consumer<Path> postExtractHook, Path properInstallDir) {
645

646
    if (postExtractHook != null) {
2✔
647
      postExtractHook.accept(properInstallDir);
3✔
648
    }
649
  }
1✔
650

651
  /**
652
   * @param path the {@link Path} to start the recursive search from.
653
   * @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
654
   *     item in their respective directory and {@code s} is not named "bin".
655
   */
656
  private Path getProperInstallationSubDirOf(Path path, Path archiveFile) {
657

658
    try (Stream<Path> stream = Files.list(path)) {
3✔
659
      Path[] subFiles = stream.toArray(Path[]::new);
8✔
660
      if (subFiles.length == 0) {
3!
661
        throw new CliException("The downloaded package " + archiveFile + " seems to be empty as you can check in the extracted folder " + path);
×
662
      } else if (subFiles.length == 1) {
4✔
663
        String filename = subFiles[0].getFileName().toString();
6✔
664
        if (!filename.equals(IdeContext.FOLDER_BIN) && !filename.equals(IdeContext.FOLDER_CONTENTS) && !filename.endsWith(".app") && Files.isDirectory(
19!
665
            subFiles[0])) {
666
          return getProperInstallationSubDirOf(subFiles[0], archiveFile);
9✔
667
        }
668
      }
669
      return path;
4✔
670
    } catch (IOException e) {
4!
671
      throw new IllegalStateException("Failed to get sub-files of " + path);
×
672
    }
673
  }
674

675
  @Override
676
  public void extractZip(Path file, Path targetDir) {
677

678
    this.context.info("Extracting ZIP file {} to {}", file, targetDir);
14✔
679
    URI uri = URI.create("jar:" + file.toUri());
6✔
680
    try (FileSystem fs = FileSystems.newFileSystem(uri, FS_ENV)) {
4✔
681
      long size = 0;
2✔
682
      for (Path root : fs.getRootDirectories()) {
11✔
683
        size += getFileSizeRecursive(root);
6✔
684
      }
1✔
685
      try (final IdeProgressBar progressBar = this.context.newProgressbarForExtracting(size)) {
5✔
686
        for (Path root : fs.getRootDirectories()) {
11✔
687
          copy(root, targetDir, FileCopyMode.EXTRACT, (s, t, d) -> onFileCopiedFromZip(s, t, d, progressBar));
15✔
688
        }
1✔
689
      }
690
    } catch (IOException e) {
×
691
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
692
    }
1✔
693
  }
1✔
694

695
  @SuppressWarnings("unchecked")
696
  private void onFileCopiedFromZip(Path source, Path target, boolean directory, IdeProgressBar progressBar) {
697

698
    if (directory) {
2✔
699
      return;
1✔
700
    }
701
    if (!context.getSystemInfo().isWindows()) {
5✔
702
      try {
703
        Object attribute = Files.getAttribute(source, "zip:permissions");
6✔
704
        if (attribute instanceof Set<?> permissionSet) {
6✔
705
          Files.setPosixFilePermissions(target, (Set<PosixFilePermission>) permissionSet);
4✔
706
        }
707
      } catch (Exception e) {
×
708
        context.error(e, "Failed to transfer zip permissions for {}", target);
×
709
      }
1✔
710
    }
711
    progressBar.stepBy(getFileSize(target));
5✔
712
  }
1✔
713

714
  @Override
715
  public void extractTar(Path file, Path targetDir, TarCompression compression) {
716

717
    extractArchive(file, targetDir, in -> new TarArchiveInputStream(compression.unpack(in)));
13✔
718
  }
1✔
719

720
  @Override
721
  public void extractJar(Path file, Path targetDir) {
722

723
    extractZip(file, targetDir);
4✔
724
  }
1✔
725

726
  /**
727
   * @param permissions The integer as returned by {@link TarArchiveEntry#getMode()} that represents the file permissions of a file on a Unix file system.
728
   * @return A String representing the file permissions. E.g. "rwxrwxr-x" or "rw-rw-r--"
729
   */
730
  public static String generatePermissionString(int permissions) {
731

732
    // Ensure that only the last 9 bits are considered
733
    permissions &= 0b111111111;
4✔
734

735
    StringBuilder permissionStringBuilder = new StringBuilder("rwxrwxrwx");
5✔
736
    for (int i = 0; i < 9; i++) {
7✔
737
      int mask = 1 << i;
4✔
738
      char currentChar = ((permissions & mask) != 0) ? permissionStringBuilder.charAt(8 - i) : '-';
12✔
739
      permissionStringBuilder.setCharAt(8 - i, currentChar);
6✔
740
    }
741

742
    return permissionStringBuilder.toString();
3✔
743
  }
744

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

747
    this.context.info("Extracting TAR file {} to {}", file, targetDir);
14✔
748
    try (InputStream is = Files.newInputStream(file);
5✔
749
        ArchiveInputStream<?> ais = unpacker.apply(is);
5✔
750
        IdeProgressBar pb = this.context.newProgressbarForExtracting(getFileSize(file))) {
7✔
751

752
      ArchiveEntry entry = ais.getNextEntry();
3✔
753
      boolean isTar = ais instanceof TarArchiveInputStream;
3✔
754
      while (entry != null) {
2✔
755
        String permissionStr = null;
2✔
756
        if (isTar) {
2!
757
          int tarMode = ((TarArchiveEntry) entry).getMode();
4✔
758
          permissionStr = generatePermissionString(tarMode);
3✔
759
        }
760
        Path entryName = Path.of(entry.getName());
6✔
761
        Path entryPath = targetDir.resolve(entryName).toAbsolutePath();
5✔
762
        if (!entryPath.startsWith(targetDir)) {
4!
763
          throw new IOException("Preventing path traversal attack from " + entryName + " to " + entryPath);
×
764
        }
765
        if (entry.isDirectory()) {
3✔
766
          mkdirs(entryPath);
4✔
767
        } else {
768
          // ensure the file can also be created if directory entry was missing or out of order...
769
          mkdirs(entryPath.getParent());
4✔
770
          Files.copy(ais, entryPath);
6✔
771
        }
772
        if (isTar && !this.context.getSystemInfo().isWindows()) {
7!
773
          Set<PosixFilePermission> permissions = PosixFilePermissions.fromString(permissionStr);
3✔
774
          Files.setPosixFilePermissions(entryPath, permissions);
4✔
775
        }
776
        pb.stepBy(entry.getSize());
4✔
777
        entry = ais.getNextEntry();
3✔
778
      }
1✔
779
    } catch (IOException e) {
×
780
      throw new IllegalStateException("Failed to extract " + file + " to " + targetDir, e);
×
781
    }
1✔
782
  }
1✔
783

784
  @Override
785
  public void extractDmg(Path file, Path targetDir) {
786

787
    this.context.info("Extracting DMG file {} to {}", file, targetDir);
×
788
    assert this.context.getSystemInfo().isMac();
×
789

790
    Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME);
×
791
    mkdirs(mountPath);
×
792
    ProcessContext pc = this.context.newProcess();
×
793
    pc.executable("hdiutil");
×
794
    pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file);
×
795
    pc.run();
×
796
    Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false);
×
797
    if (appPath == null) {
×
798
      throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file);
×
799
    }
800

801
    copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE);
×
802
    pc.addArgs("detach", "-force", mountPath);
×
803
    pc.run();
×
804
  }
×
805

806
  @Override
807
  public void extractMsi(Path file, Path targetDir) {
808

809
    this.context.info("Extracting MSI file {} to {}", file, targetDir);
×
810
    this.context.newProcess().executable("msiexec").addArgs("/a", file, "/qn", "TARGETDIR=" + targetDir).run();
×
811
    // msiexec also creates a copy of the MSI
812
    Path msiCopy = targetDir.resolve(file.getFileName());
×
813
    delete(msiCopy);
×
814
  }
×
815

816
  @Override
817
  public void extractPkg(Path file, Path targetDir) {
818

819
    this.context.info("Extracting PKG file {} to {}", file, targetDir);
×
820
    Path tmpDirPkg = createTempDir("ide-pkg-");
×
821
    ProcessContext pc = this.context.newProcess();
×
822
    // we might also be able to use cpio from commons-compression instead of external xar...
823
    pc.executable("xar").addArgs("-C", tmpDirPkg, "-xf", file).run();
×
824
    Path contentPath = findFirst(tmpDirPkg, p -> p.getFileName().toString().equals("Payload"), true);
×
825
    extractTar(contentPath, targetDir, TarCompression.GZ);
×
826
    delete(tmpDirPkg);
×
827
  }
×
828

829
  @Override
830
  public void delete(Path path) {
831

832
    if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
9✔
833
      this.context.trace("Deleting {} skipped as the path does not exist.", path);
10✔
834
      return;
1✔
835
    }
836
    this.context.debug("Deleting {} ...", path);
10✔
837
    try {
838
      if (Files.isSymbolicLink(path) || isJunction(path)) {
7!
839
        Files.delete(path);
3✔
840
      } else {
841
        deleteRecursive(path);
3✔
842
      }
843
    } catch (IOException e) {
×
844
      throw new IllegalStateException("Failed to delete " + path, e);
×
845
    }
1✔
846
  }
1✔
847

848
  private void deleteRecursive(Path path) throws IOException {
849

850
    if (Files.isDirectory(path)) {
5✔
851
      try (Stream<Path> childStream = Files.list(path)) {
3✔
852
        Iterator<Path> iterator = childStream.iterator();
3✔
853
        while (iterator.hasNext()) {
3✔
854
          Path child = iterator.next();
4✔
855
          deleteRecursive(child);
3✔
856
        }
1✔
857
      }
858
    }
859
    this.context.trace("Deleting {} ...", path);
10✔
860
    boolean isSetWritable = setWritable(path, true);
5✔
861
    if (!isSetWritable) {
2✔
862
      this.context.debug("Couldn't give write access to file: " + path);
6✔
863
    }
864
    Files.delete(path);
2✔
865
  }
1✔
866

867
  @Override
868
  public Path findFirst(Path dir, Predicate<Path> filter, boolean recursive) {
869

870
    try {
871
      if (!Files.isDirectory(dir)) {
5✔
872
        return null;
2✔
873
      }
874
      return findFirstRecursive(dir, filter, recursive);
6✔
875
    } catch (IOException e) {
×
876
      throw new IllegalStateException("Failed to search for file in " + dir, e);
×
877
    }
878
  }
879

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

882
    List<Path> folders = null;
2✔
883
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
884
      Iterator<Path> iterator = childStream.iterator();
3✔
885
      while (iterator.hasNext()) {
3✔
886
        Path child = iterator.next();
4✔
887
        if (filter.test(child)) {
4✔
888
          return child;
4✔
889
        } else if (recursive && Files.isDirectory(child)) {
2!
890
          if (folders == null) {
×
891
            folders = new ArrayList<>();
×
892
          }
893
          folders.add(child);
×
894
        }
895
      }
1✔
896
    }
4!
897
    if (folders != null) {
2!
898
      for (Path child : folders) {
×
899
        Path match = findFirstRecursive(child, filter, recursive);
×
900
        if (match != null) {
×
901
          return match;
×
902
        }
903
      }
×
904
    }
905
    return null;
2✔
906
  }
907

908
  @Override
909
  public List<Path> listChildrenMapped(Path dir, Function<Path, Path> filter) {
910

911
    if (!Files.isDirectory(dir)) {
5✔
912
      return List.of();
2✔
913
    }
914
    List<Path> children = new ArrayList<>();
4✔
915
    try (Stream<Path> childStream = Files.list(dir)) {
3✔
916
      Iterator<Path> iterator = childStream.iterator();
3✔
917
      while (iterator.hasNext()) {
3✔
918
        Path child = iterator.next();
4✔
919
        Path filteredChild = filter.apply(child);
5✔
920
        if (filteredChild != null) {
2✔
921
          if (filteredChild == child) {
3!
922
            this.context.trace("Accepted file {}", child);
11✔
923
          } else {
924
            this.context.trace("Accepted file {} and mapped to {}", child, filteredChild);
×
925
          }
926
          children.add(filteredChild);
5✔
927
        } else {
928
          this.context.trace("Ignoring file {} according to filter", child);
10✔
929
        }
930
      }
1✔
931
    } catch (IOException e) {
×
932
      throw new IllegalStateException("Failed to find children of directory " + dir, e);
×
933
    }
1✔
934
    return children;
2✔
935
  }
936

937
  @Override
938
  public boolean isEmptyDir(Path dir) {
939

940
    return listChildren(dir, f -> true).isEmpty();
8✔
941
  }
942

943
  private long getFileSize(Path file) {
944

945
    try {
946
      return Files.size(file);
3✔
947
    } catch (IOException e) {
×
948
      this.context.warning(e.getMessage(), e);
×
949
      return 0;
×
950
    }
951
  }
952

953
  private long getFileSizeRecursive(Path path) {
954

955
    long size = 0;
2✔
956
    if (Files.isDirectory(path)) {
5✔
957
      try (Stream<Path> childStream = Files.list(path)) {
3✔
958
        Iterator<Path> iterator = childStream.iterator();
3✔
959
        while (iterator.hasNext()) {
3✔
960
          Path child = iterator.next();
4✔
961
          size += getFileSizeRecursive(child);
6✔
962
        }
1✔
963
      } catch (IOException e) {
×
964
        throw new RuntimeException("Failed to iterate children of folder " + path, e);
×
965
      }
1✔
966
    } else {
967
      size += getFileSize(path);
6✔
968
    }
969
    return size;
2✔
970
  }
971

972
  @Override
973
  public Path findExistingFile(String fileName, List<Path> searchDirs) {
974

975
    for (Path dir : searchDirs) {
10!
976
      Path filePath = dir.resolve(fileName);
4✔
977
      try {
978
        if (Files.exists(filePath)) {
5✔
979
          return filePath;
2✔
980
        }
981
      } catch (Exception e) {
×
982
        throw new IllegalStateException("Unexpected error while checking existence of file " + filePath + " .", e);
×
983
      }
1✔
984
    }
1✔
985
    return null;
×
986
  }
987

988
  @Override
989
  public boolean setWritable(Path file, boolean writable) {
990
    try {
991
      // POSIX
992
      PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class);
7✔
993
      if (posix != null) {
2!
994
        Set<PosixFilePermission> permissions = new HashSet<>(posix.readAttributes().permissions());
7✔
995
        boolean changed;
996
        if (writable) {
2!
997
          changed = permissions.add(PosixFilePermission.OWNER_WRITE);
5✔
998
        } else {
999
          changed = permissions.remove(PosixFilePermission.OWNER_WRITE);
×
1000
        }
1001
        if (changed) {
2!
1002
          posix.setPermissions(permissions);
×
1003
        }
1004
        return true;
2✔
1005
      }
1006

1007
      // Windows
1008
      DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class);
×
1009
      if (dos != null) {
×
1010
        dos.setReadOnly(!writable);
×
1011
        return true;
×
1012
      }
1013

1014
      this.context.debug("Failed to set writing permission for file {}", file);
×
1015
      return false;
×
1016

1017
    } catch (IOException e) {
1✔
1018
      this.context.debug("Error occurred when trying to set writing permission for file " + file + ": " + e);
8✔
1019
      return false;
2✔
1020
    }
1021
  }
1022

1023
  @Override
1024
  public void makeExecutable(Path file, boolean confirm) {
1025

1026
    if (Files.exists(file)) {
5✔
1027
      if (SystemInfoImpl.INSTANCE.isWindows()) {
3!
1028
        this.context.trace("Windows does not have executable flags hence omitting for file {}", file);
×
1029
        return;
×
1030
      }
1031
      try {
1032
        // Read the current file permissions
1033
        Set<PosixFilePermission> existingPermissions = Files.getPosixFilePermissions(file);
5✔
1034

1035
        // Add execute permission for all users
1036
        Set<PosixFilePermission> executablePermissions = new HashSet<>(existingPermissions);
5✔
1037
        boolean update = false;
2✔
1038
        update |= executablePermissions.add(PosixFilePermission.OWNER_EXECUTE);
6✔
1039
        update |= executablePermissions.add(PosixFilePermission.GROUP_EXECUTE);
6✔
1040
        update |= executablePermissions.add(PosixFilePermission.OTHERS_EXECUTE);
6✔
1041

1042
        if (update) {
2✔
1043
          if (confirm) {
2!
1044
            boolean yesContinue = this.context.question(
×
1045
                "We want to execute " + file.getFileName() + " but this command seems to lack executable permissions!\n"
×
1046
                    + "Most probably the tool vendor did forgot to add x-flags in the binary release package.\n"
1047
                    + "Before running the command, we suggest to set executable permissions to the file:\n"
1048
                    + file + "\n"
1049
                    + "For security reasons we ask for your confirmation so please check this request.\n"
1050
                    + "Changing permissions from " + PosixFilePermissions.toString(existingPermissions) + " to " + PosixFilePermissions.toString(
×
1051
                    executablePermissions) + ".\n"
1052
                    + "Do you confirm to make the command executable before running it?");
1053
            if (!yesContinue) {
×
1054
              return;
×
1055
            }
1056
          }
1057
          this.context.debug("Setting executable flags for file {}", file);
10✔
1058
          // Set the new permissions
1059
          Files.setPosixFilePermissions(file, executablePermissions);
5✔
1060
        } else {
1061
          this.context.trace("Executable flags already present so no need to set them for file {}", file);
10✔
1062
        }
1063
      } catch (IOException e) {
×
1064
        throw new RuntimeException(e);
×
1065
      }
1✔
1066
    } else {
1067
      this.context.warning("Cannot set executable flag on file that does not exist: {}", file);
10✔
1068
    }
1069
  }
1✔
1070

1071
  @Override
1072
  public void touch(Path file) {
1073

1074
    if (Files.exists(file)) {
5✔
1075
      try {
1076
        Files.setLastModifiedTime(file, FileTime.fromMillis(System.currentTimeMillis()));
5✔
1077
      } catch (IOException e) {
×
1078
        throw new IllegalStateException("Could not update modification-time of " + file, e);
×
1079
      }
1✔
1080
    } else {
1081
      try {
1082
        Files.createFile(file);
5✔
1083
      } catch (IOException e) {
1✔
1084
        throw new IllegalStateException("Could not create empty file " + file, e);
8✔
1085
      }
1✔
1086
    }
1087
  }
1✔
1088

1089
  @Override
1090
  public String readFileContent(Path file) {
1091

1092
    this.context.trace("Reading content of file from {}", file);
10✔
1093
    if (!Files.exists((file))) {
5!
1094
      this.context.debug("File {} does not exist", file);
×
1095
      return null;
×
1096
    }
1097
    try {
1098
      String content = Files.readString(file);
3✔
1099
      this.context.trace("Completed reading {} character(s) from file {}", content.length(), file);
16✔
1100
      return content;
2✔
1101
    } catch (IOException e) {
×
1102
      throw new IllegalStateException("Failed to read file " + file, e);
×
1103
    }
1104
  }
1105

1106
  @Override
1107
  public void writeFileContent(String content, Path file, boolean createParentDir) {
1108

1109
    if (createParentDir) {
2✔
1110
      mkdirs(file.getParent());
4✔
1111
    }
1112
    if (content == null) {
2!
1113
      content = "";
×
1114
    }
1115
    this.context.trace("Writing content with {} character(s) to file {}", content.length(), file);
16✔
1116
    if (Files.exists(file)) {
5✔
1117
      this.context.info("Overriding content of file {}", file);
10✔
1118
    }
1119
    try {
1120
      Files.writeString(file, content);
6✔
1121
      this.context.trace("Wrote content to file {}", file);
10✔
1122
    } catch (IOException e) {
×
1123
      throw new RuntimeException("Failed to write file " + file, e);
×
1124
    }
1✔
1125
  }
1✔
1126

1127
  @Override
1128
  public List<String> readFileLines(Path file) {
1129

1130
    this.context.trace("Reading content of file from {}", file);
10✔
1131
    if (!Files.exists(file)) {
5✔
1132
      this.context.warning("File {} does not exist", file);
10✔
1133
      return null;
2✔
1134
    }
1135
    try {
1136
      List<String> content = Files.readAllLines(file);
3✔
1137
      this.context.trace("Completed reading {} lines from file {}", content.size(), file);
16✔
1138
      return content;
2✔
1139
    } catch (IOException e) {
×
1140
      throw new IllegalStateException("Failed to read file " + file, e);
×
1141
    }
1142
  }
1143

1144
  @Override
1145
  public void writeFileLines(List<String> content, Path file, boolean createParentDir) {
1146

1147
    if (createParentDir) {
2!
1148
      mkdirs(file.getParent());
×
1149
    }
1150
    if (content == null) {
2!
1151
      content = List.of();
×
1152
    }
1153
    this.context.trace("Writing content with {} lines to file {}", content.size(), file);
16✔
1154
    if (Files.exists(file)) {
5✔
1155
      this.context.debug("Overriding content of file {}", file);
10✔
1156
    }
1157
    try {
1158
      Files.write(file, content);
6✔
1159
      this.context.trace("Wrote content to file {}", file);
10✔
1160
    } catch (IOException e) {
×
1161
      throw new RuntimeException("Failed to write file " + file, e);
×
1162
    }
1✔
1163
  }
1✔
1164

1165
  @Override
1166
  public void readProperties(Path file, Properties properties) {
1167

1168
    try (Reader reader = Files.newBufferedReader(file)) {
3✔
1169
      properties.load(reader);
3✔
1170
      this.context.debug("Successfully loaded {} properties from {}", properties.size(), file);
16✔
1171
    } catch (IOException e) {
×
1172
      throw new IllegalStateException("Failed to read properties file: " + file, e);
×
1173
    }
1✔
1174
  }
1✔
1175

1176
  @Override
1177
  public void writeProperties(Properties properties, Path file, boolean createParentDir) {
1178

1179
    if (createParentDir) {
2✔
1180
      mkdirs(file.getParent());
4✔
1181
    }
1182
    try (Writer writer = Files.newBufferedWriter(file)) {
5✔
1183
      properties.store(writer, null); // do not get confused - Java still writes a date/time header that cannot be omitted
4✔
1184
      this.context.debug("Successfully saved {} properties to {}", properties.size(), file);
16✔
1185
    } catch (IOException e) {
×
1186
      throw new IllegalStateException("Failed to save properties file during tests.", e);
×
1187
    }
1✔
1188
  }
1✔
1189

1190
  @Override
1191
  public void readIniFile(Path file, IniFile iniFile) {
1192
    if (!Files.exists(file)) {
5!
1193
      this.context.debug("INI file {} does not exist.", iniFile);
×
1194
      return;
×
1195
    }
1196
    List<String> iniLines = readFileLines(file);
4✔
1197
    IniSection currentIniSection = null;
2✔
1198
    for (String line : iniLines) {
10✔
1199
      if (line.isEmpty()) {
3!
1200
        continue;
×
1201
      }
1202
      if (line.startsWith("[")) {
4✔
1203
        String sectionName = line.replace("[", "").replace("]", "").trim();
9✔
1204
        currentIniSection = iniFile.getOrCreateSection(sectionName);
4✔
1205
      } else {
1✔
1206
        int index = line.indexOf('=');
4✔
1207
        if (index > 0) {
2!
1208
          String propertyName = line.substring(0, index).trim();
6✔
1209
          String propertyValue = line.substring(index + 1).trim();
7✔
1210
          if (currentIniSection == null) {
2!
1211
            Log.warn("Invalid ini-file with property {} before section", propertyName);
×
1212
          } else {
1213
            currentIniSection.getProperties().put(propertyName, propertyValue);
6✔
1214
          }  
1215
        } else {
1216
          // here we can handle comments and empty lines in the future
1217
        }
1218
      }
1219
    }
1✔
1220
  }
1✔
1221

1222
  @Override
1223
  public void writeIniFile(IniFile iniFile, Path file, boolean createParentDir) {
1224
    String iniString = iniFile.toString();
3✔
1225
    writeFileContent(iniString, file, createParentDir);
5✔
1226
  }
1✔
1227

1228
  @Override
1229
  public Duration getFileAge(Path path) {
1230
    if (Files.exists(path)) {
5✔
1231
      try {
1232
        long currentTime = System.currentTimeMillis();
2✔
1233
        long fileModifiedTime = Files.getLastModifiedTime(path).toMillis();
6✔
1234
        return Duration.ofMillis(currentTime - fileModifiedTime);
5✔
1235
      } catch (IOException e) {
×
1236
        this.context.warning().log(e, "Could not get modification-time of {}.", path);
×
1237
      }
×
1238
    } else {
1239
      this.context.debug("Path {} is missing - skipping modification-time and file age check.", path);
10✔
1240
    }
1241
    return null;
2✔
1242
  }
1243

1244
  @Override
1245
  public boolean isFileAgeRecent(Path path, Duration cacheDuration) {
1246

1247
    Duration age = getFileAge(path);
4✔
1248
    if (age == null) {
2✔
1249
      return false;
2✔
1250
    }
1251
    context.debug("The path {} was last updated {} ago and caching duration is {}.", path, age, cacheDuration);
18✔
1252
    return (age.toMillis() <= cacheDuration.toMillis());
10✔
1253
  }
1254
}
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