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

devonfw / IDEasy / 28816040216

06 Jul 2026 07:00PM UTC coverage: 71.844% (+0.04%) from 71.807%
28816040216

Pull #2089

github

web-flow
Merge aa0be6480 into d1d3f7381
Pull Request #2089: #2088: skip malformed PATH entries instead of failing with InvalidPathException

4847 of 7444 branches covered (65.11%)

Branch coverage included in aggregate %.

12410 of 16576 relevant lines covered (74.87%)

3.17 hits per line

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

89.3
cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java
1
package com.devonfw.tools.ide.common;
2

3
import java.io.IOException;
4
import java.nio.file.Files;
5
import java.nio.file.InvalidPathException;
6
import java.nio.file.LinkOption;
7
import java.nio.file.Path;
8
import java.util.ArrayList;
9
import java.util.Collections;
10
import java.util.HashMap;
11
import java.util.Iterator;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Objects;
15
import java.util.function.Predicate;
16
import java.util.regex.Pattern;
17
import java.util.stream.Stream;
18

19
import org.slf4j.Logger;
20
import org.slf4j.LoggerFactory;
21

22
import com.devonfw.tools.ide.context.IdeContext;
23
import com.devonfw.tools.ide.os.SystemInfoImpl;
24
import com.devonfw.tools.ide.os.WindowsPathSyntax;
25
import com.devonfw.tools.ide.variable.IdeVariables;
26

27
/**
28
 * Represents the PATH variable in a structured way. The PATH contains the system path entries together with the entries for the IDEasy tools. The generic
29
 * system path entries are stored in a {@link List} ({@code paths}) and the tool entries are stored in a {@link Map} ({@code tool2pathMap}) as they can change
30
 * dynamically at runtime (e.g. if a new tool is installed). As the tools must have priority the actual PATH is build by first the entries for the tools and
31
 * then the generic entries from the system PATH. Such tool entries are ignored from the actual PATH of the {@link System#getenv(String) environment} at
32
 * construction time and are recomputed from the "software" folder. This is important as the initial {@link System#getenv(String) environment} PATH entries can
33
 * come from a different IDEasy project and the use may have changed projects before calling us again. Recomputing the PATH ensures side-effects from other
34
 * projects. However, it also will ensure all the entries to IDEasy locations are automatically managed and therefore cannot be managed manually be the
35
 * end-user.
36
 */
37
public class SystemPath {
38

39
  private static final Logger LOG = LoggerFactory.getLogger(SystemPath.class);
3✔
40

41
  private static final Pattern REGEX_WINDOWS_PATH = Pattern.compile("([a-zA-Z]:)?(\\\\[a-zA-Z0-9\\s_.-]+)+\\\\?");
3✔
42

43
  private final char pathSeparator;
44

45
  private final Map<String, Path> tool2pathMap;
46

47
  private final List<Path> paths;
48

49
  private final List<Path> extraPathEntries;
50

51
  private final IdeContext context;
52

53
  private static final List<String> EXTENSION_PRIORITY = List.of(".exe", ".cmd", ".bat", ".msi", ".ps1", "");
9✔
54

55
  /**
56
   * The constructor.
57
   *
58
   * @param context {@link IdeContext}.
59
   */
60
  public SystemPath(IdeContext context) {
61

62
    this(context, System.getenv(IdeVariables.PATH.getName()));
×
63
  }
×
64

65
  /**
66
   * The constructor.
67
   *
68
   * @param context {@link IdeContext}.
69
   * @param envPath the value of the PATH variable.
70
   */
71
  public SystemPath(IdeContext context, String envPath) {
72

73
    this(context, envPath, context.getIdeRoot(), context.getSoftwarePath());
8✔
74
  }
1✔
75

76
  /**
77
   * The constructor.
78
   *
79
   * @param context {@link IdeContext} for the output of information.
80
   * @param envPath the value of the PATH variable.
81
   * @param ideRoot the {@link IdeContext#getIdeRoot() IDE_ROOT}.
82
   * @param softwarePath the {@link IdeContext#getSoftwarePath() software path}.
83
   */
84
  public SystemPath(IdeContext context, String envPath, Path ideRoot, Path softwarePath) {
85

86
    this(context, envPath, ideRoot, softwarePath, System.getProperty("path.separator").charAt(0), Collections.emptyList());
11✔
87
  }
1✔
88

89
  /**
90
   * The constructor.
91
   *
92
   * @param context {@link IdeContext} for the output of information.
93
   * @param envPath the value of the PATH variable.
94
   * @param ideRoot the {@link IdeContext#getIdeRoot() IDE_ROOT}.
95
   * @param softwarePath the {@link IdeContext#getSoftwarePath() software path}.
96
   * @param pathSeparator the path separator char (';' for Windows and ':' otherwise).
97
   * @param extraPathEntries the {@link List} of additional {@link Path}s to prepend.
98
   */
99
  public SystemPath(IdeContext context, String envPath, Path ideRoot, Path softwarePath, char pathSeparator, List<Path> extraPathEntries) {
100

101
    this(context, pathSeparator, extraPathEntries, new HashMap<>(), new ArrayList<>());
11✔
102
    String[] envPaths = envPath.split(Character.toString(pathSeparator));
5✔
103
    for (String segment : envPaths) {
16✔
104
      Path path = parsePathSegment(segment);
3✔
105
      if (path == null) {
2✔
106
        continue;
1✔
107
      }
108
      String tool = getTool(path, ideRoot);
4✔
109
      if (tool == null) {
2!
110
        this.paths.add(path);
5✔
111
      }
112
    }
113
    collectToolPath(softwarePath);
3✔
114
  }
1✔
115

116
  /**
117
   * Parses a single {@code PATH} segment into a {@link Path}. The segment is first {@link #normalizePathSegment(String) normalized} by removing illegal control
118
   * characters (e.g. CR, LF, NUL, TAB) that may sneak into the {@code PATH} variable (e.g. via broken tool configurations) and would otherwise render the entry
119
   * invalid. This way a still usable entry is kept instead of being dropped. If nothing usable remains after normalization or the segment cannot be parsed as a
120
   * {@link Path} even after normalization, {@code null} is returned so the entry is skipped without aborting the entire {@link SystemPath} construction.
121
   *
122
   * @param segment the raw {@code PATH} segment.
123
   * @return the parsed {@link Path} or {@code null} if the segment is invalid and should be ignored.
124
   */
125
  private static Path parsePathSegment(String segment) {
126

127
    String normalized = normalizePathSegment(segment);
3✔
128
    if (normalized.isEmpty()) {
3✔
129
      if (!segment.isEmpty()) {
3✔
130
        LOG.warn("Ignoring invalid PATH entry '{}' as it only contains illegal control characters.", segment);
4✔
131
      }
132
      return null;
2✔
133
    }
134
    if (!normalized.equals(segment)) {
4✔
135
      LOG.warn("Normalized PATH entry '{}' by removing illegal control characters.", segment);
4✔
136
    }
137
    try {
138
      return Path.of(normalized);
5✔
139
    } catch (InvalidPathException e) {
×
140
      LOG.warn("Ignoring invalid PATH entry '{}' - {}", segment, e.getMessage());
×
141
      return null;
×
142
    }
143
  }
144

145
  /**
146
   * @param segment the raw {@code PATH} segment.
147
   * @return the given {@code segment} with all {@link Character#isISOControl(char) ISO control characters} (e.g. CR, LF, NUL, TAB) removed.
148
   */
149
  private static String normalizePathSegment(String segment) {
150

151
    StringBuilder sb = new StringBuilder(segment.length());
6✔
152
    for (int i = 0; i < segment.length(); i++) {
8✔
153
      char c = segment.charAt(i);
4✔
154
      if (!Character.isISOControl(c)) {
3✔
155
        sb.append(c);
4✔
156
      }
157
    }
158
    return sb.toString();
3✔
159
  }
160

161
  /**
162
   * @param context {@link IdeContext} for the output of information.
163
   * @param softwarePath the {@link IdeContext#getSoftwarePath() software path}.
164
   * @param pathSeparator the path separator char (';' for Windows and ':' otherwise).
165
   * @param paths the {@link List} of {@link Path}s to use in the PATH environment variable.
166
   */
167
  public SystemPath(IdeContext context, Path softwarePath, char pathSeparator, List<Path> paths) {
168
    this(context, pathSeparator, new ArrayList<>(), new HashMap<>(), paths);
11✔
169
    collectToolPath(softwarePath);
3✔
170
  }
1✔
171

172
  private SystemPath(IdeContext context, char pathSeparator, List<Path> extraPathEntries, Map<String, Path> tool2PathMap, List<Path> paths) {
173

174
    super();
2✔
175
    this.context = context;
3✔
176
    this.pathSeparator = pathSeparator;
3✔
177
    this.extraPathEntries = extraPathEntries;
3✔
178
    this.tool2pathMap = tool2PathMap;
3✔
179
    this.paths = paths;
3✔
180
  }
1✔
181

182
  private void collectToolPath(Path softwarePath) {
183

184
    if (softwarePath == null) {
2✔
185
      return;
1✔
186
    }
187
    if (Files.isDirectory(softwarePath)) {
5✔
188
      try (Stream<Path> children = Files.list(softwarePath)) {
3✔
189
        Iterator<Path> iterator = children.iterator();
3✔
190
        while (iterator.hasNext()) {
3✔
191
          Path child = iterator.next();
4✔
192
          String tool = child.getFileName().toString();
4✔
193
          if (!"extra".equals(tool) && Files.isDirectory(child)) {
9!
194
            Path toolPath = child;
2✔
195
            Path bin = child.resolve("bin");
4✔
196
            if (Files.isDirectory(bin)) {
5✔
197
              toolPath = bin;
2✔
198
            }
199
            this.tool2pathMap.put(tool, toolPath);
6✔
200
          }
201
        }
1✔
202
      } catch (IOException e) {
×
203
        throw new IllegalStateException("Failed to list children of " + softwarePath, e);
×
204
      }
1✔
205
    }
206
  }
1✔
207

208
  private static String getTool(Path path, Path ideRoot) {
209

210
    if (ideRoot == null) {
2✔
211
      return null;
2✔
212
    }
213
    if (path.startsWith(ideRoot)) {
4✔
214
      Path relativized = ideRoot.relativize(path);
4✔
215
      int count = relativized.getNameCount();
3✔
216
      if (count >= 3) {
3✔
217
        if (relativized.getName(1).toString().equals("software")) {
7!
218
          return relativized.getName(2).toString();
×
219
        }
220
      }
221
    }
222
    return null;
2✔
223
  }
224

225
  private Path findBinaryInOrder(Path path, String tool) {
226

227
    List<String> extensionPriority = List.of("");
3✔
228
    if (this.context.getSystemInfo().isWindows() || SystemInfoImpl.INSTANCE.isWindows()) {
8!
229
      extensionPriority = EXTENSION_PRIORITY;
2✔
230
    }
231
    for (String extension : extensionPriority) {
10✔
232

233
      Path fileToExecute = path.resolve(tool + extension);
6✔
234

235
      if (Files.exists(fileToExecute, LinkOption.NOFOLLOW_LINKS)) {
9✔
236
        return fileToExecute;
2✔
237
      }
238
    }
1✔
239

240
    return null;
2✔
241
  }
242

243
  /**
244
   * @param binaryName the name of the tool.
245
   * @return {@code true} if the given {@code tool} is a binary that can be found on the PATH, {@code false} otherwise.
246
   */
247
  public boolean hasBinaryOnPath(String binaryName) {
248
    Path binary = Path.of(binaryName);
×
249
    Path resolvedBinary = findBinary(binary);
×
250
    return (resolvedBinary != binary);
×
251
  }
252

253
  /**
254
   * @param binaryName the name of the tool.
255
   * @return the {@link Path} to the binary executable of the tool. E.g. if "mvn" is given then ".../software/mvn/bin/mvn" could be returned. If the executable
256
   *     was not found on PATH, the same {@link Path} instance is returned that was given as argument.
257
   */
258
  public Path findBinaryPathByName(String binaryName) {
259
    return findBinary(Path.of(binaryName));
×
260
  }
261

262
  /**
263
   * @param toolPath the {@link Path} to the tool installation.
264
   * @return the {@link Path} to the binary executable of the tool. E.g. if "mvn" is given then ".../software/mvn/bin/mvn" could be returned. If the executable
265
   *     was not found on PATH, the same {@link Path} instance is returned that was given as argument.
266
   */
267
  public Path findBinary(Path toolPath) {
268
    return findBinary(toolPath, p -> true);
7✔
269
  }
270

271
  /**
272
   * Finds a binary for {@code toolPath} but allows excluding candidates via {@code filter}. If the filter rejects a candidate, the search continues. If no
273
   * acceptable candidate is found, the original {@code toolPath} is returned unchanged.
274
   *
275
   * @param toolPath the {@link Path} to the tool installation or a simple name (e.g., "git").
276
   * @param filter a predicate that must return {@code true} for acceptable candidates; {@code false} rejects and continues searching.
277
   * @return the first acceptable {@link Path} found; if none, the original {@code toolPath}.
278
   */
279
  public Path findBinary(Path toolPath, Predicate<Path> filter) {
280
    Objects.requireNonNull(toolPath, "toolPath");
4✔
281
    Objects.requireNonNull(filter, "filter");
4✔
282

283
    Path parent = toolPath.getParent();
3✔
284
    String fileName = toolPath.getFileName().toString();
4✔
285

286
    if (parent == null) {
2✔
287
      for (Path path : this.tool2pathMap.values()) {
12✔
288
        Path binaryPath = findBinaryInOrder(path, fileName);
5✔
289
        if (binaryPath != null && filter.test(binaryPath)) {
6✔
290
          return binaryPath;
2✔
291
        }
292
      }
1✔
293
      for (Path path : this.paths) {
11✔
294
        Path binaryPath = findBinaryInOrder(path, fileName);
5✔
295
        if (binaryPath != null && filter.test(binaryPath)) {
6✔
296
          return binaryPath;
2✔
297
        }
298
      }
2✔
299
    } else {
300
      Path binaryPath = findBinaryInOrder(parent, fileName);
5✔
301
      if (binaryPath != null && filter.test(binaryPath)) {
6!
302
        return binaryPath;
2✔
303
      }
304
    }
305

306
    return toolPath;
2✔
307
  }
308

309
  /**
310
   * @param tool the name of the tool.
311
   * @return the {@link Path} to the directory of the tool where the binaries can be found or {@code null} if the tool is not installed.
312
   */
313
  public Path getPath(String tool) {
314

315
    return this.tool2pathMap.get(tool);
6✔
316
  }
317

318
  /**
319
   * @param tool the name of the tool.
320
   * @param path the new {@link #getPath(String) tool bin path}.
321
   */
322
  public void setPath(String tool, Path path) {
323

324
    this.tool2pathMap.put(tool, path);
6✔
325
  }
1✔
326

327
  @Override
328
  public String toString() {
329

330
    return toString(null);
4✔
331
  }
332

333
  /**
334
   * @param pathSyntax the {@link WindowsPathSyntax} to convert to.
335
   * @return this {@link SystemPath} as {@link String} for the PATH environment variable.
336
   */
337
  public String toString(WindowsPathSyntax pathSyntax) {
338

339
    char separator;
340
    if (pathSyntax == WindowsPathSyntax.MSYS) {
3!
341
      separator = ':';
×
342
    } else {
343
      separator = this.pathSeparator;
3✔
344
    }
345
    StringBuilder sb = new StringBuilder(128);
5✔
346
    for (Path path : this.extraPathEntries) {
11✔
347
      appendPath(path, sb, separator, pathSyntax);
5✔
348
    }
1✔
349
    for (Path path : this.tool2pathMap.values()) {
12✔
350
      appendPath(path, sb, separator, pathSyntax);
5✔
351
    }
1✔
352
    for (Path path : this.paths) {
11✔
353
      appendPath(path, sb, separator, pathSyntax);
5✔
354
    }
1✔
355
    return sb.toString();
3✔
356
  }
357

358
  /**
359
   * Derive a new {@link SystemPath} from this instance with the given parameters.
360
   *
361
   * @param overriddenPath the entire PATH to override and replace the current one from this {@link SystemPath} or {@code null} to keep the current PATH.
362
   * @param extraPathEntries the {@link List} of additional PATH entries to add to the beginning of the PATH. May be empty to add nothing.
363
   * @return the new {@link SystemPath} derived from this instance with the given parameters applied.
364
   */
365
  public SystemPath withPath(String overriddenPath, List<Path> extraPathEntries) {
366

367
    if (overriddenPath == null) {
2!
368
      return new SystemPath(this.context, this.pathSeparator, extraPathEntries, this.tool2pathMap, this.paths);
13✔
369
    } else {
370
      return new SystemPath(this.context, overriddenPath, null, null, this.pathSeparator, extraPathEntries);
×
371
    }
372
  }
373

374
  private static void appendPath(Path path, StringBuilder sb, char separator, WindowsPathSyntax pathSyntax) {
375

376
    if (sb.length() > 0) {
3✔
377
      sb.append(separator);
4✔
378
    }
379
    String pathString;
380
    if (pathSyntax == null) {
2✔
381
      pathString = path.toString();
4✔
382
    } else {
383
      pathString = pathSyntax.format(path);
4✔
384
    }
385
    sb.append(pathString);
4✔
386
  }
1✔
387

388
  /**
389
   * Method to validate if a given path string is a Windows path or not
390
   *
391
   * @param pathString The string to check if it is a Windows path string.
392
   * @return {@code true} if it is a valid windows path string, else {@code false}.
393
   */
394
  public static boolean isValidWindowsPath(String pathString) {
395

396
    return REGEX_WINDOWS_PATH.matcher(pathString).matches();
5✔
397
  }
398
}
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