diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/TokenReplace.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/TokenReplace.java new file mode 100644 index 0000000000000..7c213851b9bf7 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/TokenReplace.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jdk.jpackage.internal.util; + +import static java.util.stream.Collectors.joining; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +/** + * Class to replace tokens in strings. + *

+ * Single instance holds a list of tokens. Tokens can be substrings of each other. + * The implementation performs greedy replacement: longer tokens are replaced first. + */ +public final class TokenReplace { + + private record TokenCut(String[] main, String[] sub) { + static String[] orderTokens(String... tokens) { + if (tokens.length == 0) { + throw new IllegalArgumentException("Empty token list"); + } + + final var orderedTokens = Stream.of(tokens) + .sorted(Comparator.naturalOrder().thenComparing(Comparator.comparingInt(String::length))) + .distinct() + .toArray(String[]::new); + + if (orderedTokens[0].isEmpty()) { + throw new IllegalArgumentException("Empty token in the list of tokens"); + } + + return orderedTokens; + } + + static TokenCut createFromOrderedTokens(String... tokens) { + final List subTokens = new ArrayList<>(); + + for (var i = 0; i < tokens.length - 1; ++i) { + final var x = tokens[i]; + for (var j = i + 1; j < tokens.length; ++j) { + final var y = tokens[j]; + if (y.contains(x)) { + subTokens.add(i); + } + } + } + + if (subTokens.isEmpty()) { + return new TokenCut(tokens, null); + } else { + final var main = IntStream.range(0, tokens.length) + .mapToObj(Integer::valueOf) + .filter(Predicate.not(subTokens::contains)) + .map(i -> { + return tokens[i]; + }).toArray(String[]::new); + final var sub = subTokens.stream().map(i -> { + return tokens[i]; + }).toArray(String[]::new); + return new TokenCut(main, sub); + } + } + + @Override + public String toString() { + return String.format("TokenCut(main=%s, sub=%s)", Arrays.toString(main), Arrays.toString(sub)); + } + } + + public TokenReplace(String... tokens) { + tokens = TokenCut.orderTokens(tokens); + + this.tokens = tokens; + regexps = new ArrayList<>(); + + for (;;) { + final var tokenCut = TokenCut.createFromOrderedTokens(tokens); + regexps.add(Pattern.compile(Stream.of(tokenCut.main()).map(Pattern::quote).collect(joining("|", "(", ")")))); + + if (tokenCut.sub() == null) { + break; + } + + tokens = tokenCut.sub(); + } + } + + public String applyTo(String str, Function tokenValueSupplier) { + Objects.requireNonNull(str); + Objects.requireNonNull(tokenValueSupplier); + for (final var regexp : regexps) { + str = regexp.matcher(str).replaceAll(mr -> { + final var token = mr.group(); + return Matcher.quoteReplacement(Objects.requireNonNull(tokenValueSupplier.apply(token), () -> { + return String.format("Null value for token [%s]", token); + }).toString()); + }); + } + return str; + } + + public String recursiveApplyTo(String str, Function tokenValueSupplier) { + String newStr; + int counter = tokens.length + 1; + while (!(newStr = applyTo(str, tokenValueSupplier)).equals(str)) { + str = newStr; + if (counter-- == 0) { + throw new IllegalStateException("Infinite recursion"); + } + } + return newStr; + } + + @Override + public int hashCode() { + // Auto generated code + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(tokens); + return result; + } + + @Override + public boolean equals(Object obj) { + // Auto generated code + if (this == obj) { + return true; + } + if ((obj == null) || (getClass() != obj.getClass())) { + return false; + } + TokenReplace other = (TokenReplace) obj; + return Arrays.equals(tokens, other.tokens); + } + + @Override + public String toString() { + return "TokenReplace(" + String.join("|", tokens) + ")"; + } + + public static TokenReplace combine(TokenReplace x, TokenReplace y) { + return new TokenReplace(Stream.of(x.tokens, y.tokens).flatMap(Stream::of).toArray(String[]::new)); + } + + public static Function createCachingTokenValueSupplier(Map> tokenValueSuppliers) { + Objects.requireNonNull(tokenValueSuppliers); + final Map cache = new HashMap<>(); + return token -> { + final var value = cache.computeIfAbsent(token, k -> { + final var tokenValueSupplier = Objects.requireNonNull(tokenValueSuppliers.get(token), () -> { + return String.format("No token value supplier for token [%s]", token); + }); + return Optional.ofNullable(tokenValueSupplier.get()).orElse(NULL_SUPPLIED); + }); + + if (value == NULL_SUPPLIED) { + throw new NullPointerException(String.format("Null value for token [%s]", token)); + } + + return value; + }; + } + + private final String[] tokens; + private final transient List regexps; + private final static Object NULL_SUPPLIED = new Object(); +} diff --git a/src/jdk.jpackage/share/native/applauncher/AppLauncher.cpp b/src/jdk.jpackage/share/native/applauncher/AppLauncher.cpp index 96e8885e6ab50..fd1d07e92e05a 100644 --- a/src/jdk.jpackage/share/native/applauncher/AppLauncher.cpp +++ b/src/jdk.jpackage/share/native/applauncher/AppLauncher.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -164,9 +164,9 @@ CfgFile* AppLauncher::createCfgFile() const { << cfgFilePath << "\""); CfgFile::Macros macros; - macros[_T("$APPDIR")] = appDirPath; - macros[_T("$BINDIR")] = FileUtils::dirname(launcherPath); - macros[_T("$ROOTDIR")] = imageRoot; + macros[_T("APPDIR")] = appDirPath; + macros[_T("BINDIR")] = FileUtils::dirname(launcherPath); + macros[_T("ROOTDIR")] = imageRoot; std::unique_ptr dummy(new CfgFile()); CfgFile::load(cfgFilePath).expandMacros(macros).swap(*dummy); return dummy.release(); diff --git a/src/jdk.jpackage/share/native/applauncher/CfgFile.cpp b/src/jdk.jpackage/share/native/applauncher/CfgFile.cpp index b5049334b6482..0e99d131e69ec 100644 --- a/src/jdk.jpackage/share/native/applauncher/CfgFile.cpp +++ b/src/jdk.jpackage/share/native/applauncher/CfgFile.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -25,13 +25,16 @@ #include "kludge_c++11.h" +#include #include #include #include "CfgFile.h" +#include "SysInfo.h" #include "Log.h" #include "Toolbox.h" #include "FileUtils.h" #include "ErrorHandling.h" +#include "StringProcessing.h" const CfgFile::Properties& CfgFile::getProperties( @@ -60,38 +63,82 @@ CfgFile& CfgFile::setPropertyValue(const SectionName& sectionName, namespace { -tstring expandMacros(const tstring& str, const CfgFile::Macros& macros) { - tstring reply = str; - CfgFile::Macros::const_iterator it = macros.begin(); - const CfgFile::Macros::const_iterator end = macros.end(); - for (; it != end; ++it) { - reply = tstrings::replace(reply, it->first, it->second); +template +void iterateProperties(CfgFileType& cfgFile, OpType& op) { + for (auto mapIt = cfgFile.begin(), mapEnd = cfgFile.end(); mapIt != mapEnd; ++mapIt) { + for (auto propertyIt = mapIt->second.begin(), propertyEnd = mapIt->second.end(); propertyIt != propertyEnd; ++propertyIt) { + for (auto strIt = propertyIt->second.begin(), strEnd = propertyIt->second.end(); strIt != strEnd; ++strIt) { + op(strIt); + } + } } - return reply; } +struct tokenize_strings { + void operator () (tstring_array::const_iterator& strIt) { + const auto tokens = StringProcessing::tokenize(*strIt); + values.push_back(tokens); + } + + std::set variableNames() const { + std::set allVariableNames; + for (auto it = values.begin(), end = values.end(); it != end; ++it) { + const auto variableNames = StringProcessing::extractVariableNames(*it); + allVariableNames.insert(variableNames.begin(), variableNames.end()); + } + + return allVariableNames; + } + + std::vector values; +}; + +class expand_macros { +public: + expand_macros(const CfgFile::Macros& m, + std::vector::iterator iter) : macros(m), curTokenizedString(iter) { + } + + void operator () (tstring_array::iterator& strIt) { + StringProcessing::expandVariables(*curTokenizedString, macros); + auto newStr = StringProcessing::stringify(*curTokenizedString); + ++curTokenizedString; + if (*strIt != newStr) { + LOG_TRACE(tstrings::any() << "Map [" << *strIt << "] into [" << newStr << "]"); + } + strIt->swap(newStr); + } + +private: + const CfgFile::Macros& macros; + std::vector::iterator curTokenizedString; +}; + } // namespace CfgFile CfgFile::expandMacros(const Macros& macros) const { CfgFile copyCfgFile = *this; - PropertyMap::iterator mapIt = copyCfgFile.data.begin(); - const PropertyMap::iterator mapEnd = copyCfgFile.data.end(); - for (; mapIt != mapEnd; ++mapIt) { - Properties::iterator propertyIt = mapIt->second.begin(); - const Properties::iterator propertyEnd = mapIt->second.end(); - for (; propertyIt != propertyEnd; ++propertyIt) { - tstring_array::iterator strIt = propertyIt->second.begin(); - const tstring_array::iterator strEnd = propertyIt->second.end(); - for (; strIt != strEnd; ++strIt) { - tstring newValue; - while ((newValue = ::expandMacros(*strIt, macros)) != *strIt) { - strIt->swap(newValue); - } + tokenize_strings tokenizedStrings; + iterateProperties(static_cast(copyCfgFile.data), tokenizedStrings); + + Macros allMacros(macros); + + const auto variableNames = tokenizedStrings.variableNames(); + for (auto it = variableNames.begin(), end = variableNames.end(); it != end; ++it) { + if (macros.find(*it) == macros.end()) { + // Not one of the reserved macro names. Assuming an environment variable. + const auto envVarName = *it; + if (SysInfo::isEnvVariableSet(envVarName)) { + const auto envVarValue = SysInfo::getEnvVariable(envVarName); + allMacros[envVarName] = envVarValue; } } } + expand_macros expandMacros(allMacros, tokenizedStrings.values.begin()); + iterateProperties(copyCfgFile.data, expandMacros); + return copyCfgFile; } diff --git a/src/jdk.jpackage/share/native/applauncher/CfgFile.h b/src/jdk.jpackage/share/native/applauncher/CfgFile.h index 03f70d94fde06..b1edea237a3ea 100644 --- a/src/jdk.jpackage/share/native/applauncher/CfgFile.h +++ b/src/jdk.jpackage/share/native/applauncher/CfgFile.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -93,7 +93,7 @@ class CfgFile { typedef std::map Macros; /** - * Returns copy of this instance with the given macros expanded. + * Returns copy of this instance with the given macros and environment variables expanded. */ CfgFile expandMacros(const Macros& macros) const; diff --git a/src/jdk.jpackage/share/native/applauncher/StringProcessing.cpp b/src/jdk.jpackage/share/native/applauncher/StringProcessing.cpp new file mode 100644 index 0000000000000..207db25fd94ba --- /dev/null +++ b/src/jdk.jpackage/share/native/applauncher/StringProcessing.cpp @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +#include "kludge_c++11.h" + +#include +#include "StringProcessing.h" + + +namespace StringProcessing { + +namespace { + +class TokenBuilder { +public: + TokenBuilder(const tstring& v): cur(v.c_str()) { + } + + void addNextToken(tstring::const_pointer end, TokenType type, TokenizedString& tokens) { + if (end != cur) { + const auto value = tstring(cur, end - cur); + cur = end; + tokens.push_back(Token(type, value)); + } + } + +private: + tstring::const_pointer cur; +}; + +bool isValidVariableFirstChar(tstring::value_type chr) { + if ('A' <= chr && chr <= 'Z') { + return true; + } else if ('a' <= chr && chr <= 'z') { + return true; + } else if ('_' == chr) { + return true; + } else { + return false; + } +} + +bool isValidVariableOtherChar(tstring::value_type chr) { + if (isValidVariableFirstChar(chr)) { + return true; + } else if ('0' <= chr && chr <= '9') { + return true; + } else { + return false; + } +} + +} // namespace + +TokenizedString tokenize(const tstring& str) { + TokenizedString tokens; + TokenBuilder tb(str); + tstring::const_pointer cur = str.c_str(); + const tstring::const_pointer end = cur + str.length(); + while (cur != end) { + if (*cur == '\\' && cur + 1 != end) { + const auto maybeNextToken = cur++; + if (*cur == '\\' || *cur == '$') { + tb.addNextToken(maybeNextToken, STRING, tokens); + tb.addNextToken(++cur, ESCAPED_CHAR, tokens); + continue; + } + } else if (*cur == '$' && cur + 1 != end) { + const auto maybeNextToken = cur++; + bool variableFound = false; + if (*cur == '{') { + do { + cur++; + } while (cur != end && *cur != '}'); + if (cur != end) { + variableFound = true; + cur++; + } + } else if (isValidVariableFirstChar(*cur)) { + variableFound = true; + do { + cur++; + } while (cur != end && isValidVariableOtherChar(*cur)); + } else { + continue; + } + if (variableFound) { + tb.addNextToken(maybeNextToken, STRING, tokens); + tb.addNextToken(cur, VARIABLE, tokens); + } + } else { + ++cur; + } + } + tb.addNextToken(cur, STRING, tokens); + return tokens; +} + + +tstring stringify(const TokenizedString& tokens) { + tstringstream ss; + TokenizedString::const_iterator it = tokens.begin(); + const TokenizedString::const_iterator end = tokens.end(); + for (; it != end; ++it) { + if (it->type() == ESCAPED_CHAR) { + ss << it->value().substr(1); + } else { + ss << it->value(); + } + } + return ss.str(); +} + + +namespace { + +tstring getVariableName(const tstring& str) { + if (tstrings::endsWith(str, _T("}"))) { + // ${VAR} + return str.substr(2, str.length() - 3); + } else { + // $VAR + return str.substr(1); + } +} + +} // namespace + +VariableNameList extractVariableNames(const TokenizedString& tokens) { + VariableNameList reply; + + TokenizedString::const_iterator it = tokens.begin(); + const TokenizedString::const_iterator end = tokens.end(); + for (; it != end; ++it) { + if (it->type() == VARIABLE) { + reply.push_back(getVariableName(it->value())); + } + } + + std::sort(reply.begin(), reply.end()); + reply.erase(std::unique(reply.begin(), reply.end()), reply.end()); + return reply; +} + + +void expandVariables(TokenizedString& tokens, const VariableValues& variableValues) { + TokenizedString::iterator it = tokens.begin(); + const TokenizedString::iterator end = tokens.end(); + for (; it != end; ++it) { + if (it->type() == VARIABLE) { + const auto entry = variableValues.find(getVariableName(it->value())); + if (entry != variableValues.end()) { + auto newToken = Token(STRING, entry->second); + std::swap(*it, newToken); + } + } + } +} + +} // namespace StringProcessing diff --git a/src/jdk.jpackage/share/native/applauncher/StringProcessing.h b/src/jdk.jpackage/share/native/applauncher/StringProcessing.h new file mode 100644 index 0000000000000..cdd1120d62060 --- /dev/null +++ b/src/jdk.jpackage/share/native/applauncher/StringProcessing.h @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + + +#ifndef StringProcessor_h +#define StringProcessor_h + +#include +#include "tstrings.h" + + +namespace StringProcessing { + +enum TokenType { + STRING, + VARIABLE, + ESCAPED_CHAR +}; + +class Token { +public: + Token(TokenType type, const tstring& str): theType(type), theStr(str) { + } + + TokenType type() const { + return theType; + } + + const tstring& value() const { + return theStr; + } + +private: + TokenType theType; + tstring theStr; +}; + +typedef std::vector TokenizedString; +typedef std::vector VariableNameList; +typedef std::map VariableValues; + +TokenizedString tokenize(const tstring& str); + +tstring stringify(const TokenizedString& tokens); + +VariableNameList extractVariableNames(const TokenizedString& tokens); + +void expandVariables(TokenizedString& tokens, const VariableValues& variableValues); + +} // namespace StringProcessing + +#endif // StringProcessor_h diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AdditionalLauncher.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AdditionalLauncher.java index 5089dc0c60204..f10939555e57a 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AdditionalLauncher.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/AdditionalLauncher.java @@ -22,6 +22,9 @@ */ package jdk.jpackage.test; +import static java.util.stream.Collectors.toMap; +import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -35,11 +38,8 @@ import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; import java.util.stream.Stream; -import static jdk.jpackage.internal.util.function.ExceptionBox.rethrowUnchecked; import jdk.jpackage.internal.util.function.ThrowingBiConsumer; -import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; public class AdditionalLauncher { @@ -393,11 +393,9 @@ public static final class PropertyFile { PropertyFile(Path path) throws IOException { data = Files.readAllLines(path).stream().map(str -> { return str.split("=", 2); - }).collect( - Collectors.toMap(tokens -> tokens[0], tokens -> tokens[1], - (oldValue, newValue) -> { - return newValue; - })); + }).collect(toMap(tokens -> tokens[0], tokens -> tokens[1], (oldValue, newValue) -> { + return newValue; + })); } public boolean isPropertySet(String name) { @@ -419,11 +417,9 @@ public Optional getPropertyBooleanValue(String name) { } private static String resolveVariables(JPackageCommand cmd, String str) { - var map = Map.of( - "$APPDIR", cmd.appLayout().appDirectory(), - "$ROOTDIR", - cmd.isImagePackageType() ? cmd.outputBundle() : cmd.appInstallationDirectory(), - "$BINDIR", cmd.appLayout().launchersDirectory()); + var map = Stream.of(JPackageCommand.Macro.values()).collect(toMap(x -> { + return String.format("$%s", x.name()); + }, cmd::macroValue)); for (var e : map.entrySet()) { str = str.replaceAll(Pattern.quote(e.getKey()), Matcher.quoteReplacement(e.getValue().toString())); diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java index a8a849cb9e786..cd1db30e9c9dc 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/Executor.java @@ -33,8 +33,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -92,6 +94,13 @@ public Executor setExecutable(JavaTool v) { public Executor removeEnvVar(String envVarName) { removeEnvVars.add(Objects.requireNonNull(envVarName)); + setEnvVars.remove(envVarName); + return this; + } + + public Executor setEnvVar(String envVarName, String envVarValue) { + setEnvVars.put(Objects.requireNonNull(envVarName), Objects.requireNonNull(envVarValue)); + removeEnvVars.remove(envVarName); return this; } @@ -370,9 +379,25 @@ private Result runExecutable() throws IOException, InterruptedException { builder.directory(directory.toFile()); sb.append(String.format("; in directory [%s]", directory)); } + if (!setEnvVars.isEmpty()) { + final var defaultEnv = builder.environment(); + final var envComm = Comm.compare(defaultEnv.keySet(), setEnvVars.keySet()); + envComm.unique2().forEach(envVar -> { + TKit.trace(String.format("Adding %s=[%s] to environment", envVar, setEnvVars.get(envVar))); + }); + envComm.common().forEach(envVar -> { + final var curValue = defaultEnv.get(envVar); + final var newValue = setEnvVars.get(envVar); + if (!curValue.equals(newValue)) { + TKit.trace(String.format("Setting %s=[%s] in environment", envVar, setEnvVars.get(envVar))); + } + }); + defaultEnv.putAll(setEnvVars); + } if (!removeEnvVars.isEmpty()) { - final var envComm = Comm.compare(builder.environment().keySet(), removeEnvVars); - builder.environment().keySet().removeAll(envComm.common()); + final var defaultEnv = builder.environment().keySet(); + final var envComm = Comm.compare(defaultEnv, removeEnvVars); + defaultEnv.removeAll(envComm.common()); envComm.common().forEach(envVar -> { TKit.trace(String.format("Clearing %s in environment", envVar)); }); @@ -515,6 +540,7 @@ private static void trace(String msg) { private Set saveOutputType; private Path directory; private Set removeEnvVars = new HashSet<>(); + private Map setEnvVars = new HashMap<>(); private boolean winEnglishOutput; private String winTmpDir = null; diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java index efcd004157916..9e67dbe63c5de 100644 --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/JPackageCommand.java @@ -827,6 +827,32 @@ public JPackageCommand assertImageCreated() { return this; } + public static enum Macro { + APPDIR(cmd -> { + return cmd.appLayout().appDirectory().toString(); + }), + BINDIR(cmd -> { + return cmd.appLayout().launchersDirectory().toString(); + }), + ROOTDIR(cmd -> { + return (cmd.isImagePackageType() ? cmd.outputBundle() : cmd.appInstallationDirectory()).toString(); + }); + + private Macro(Function getValue) { + this.getValue = Objects.requireNonNull(getValue); + } + + String value(JPackageCommand cmd) { + return getValue.apply(cmd); + } + + private final Function getValue; + } + + public String macroValue(Macro macro) { + return macro.value(this); + } + public static enum AppLayoutAssert { APP_IMAGE_FILE(JPackageCommand::assertAppImageFile), PACKAGE_FILE(JPackageCommand::assertPackageFile), diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/TokenReplaceTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/TokenReplaceTest.java new file mode 100644 index 0000000000000..01d1d10ef9d5b --- /dev/null +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/util/TokenReplaceTest.java @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.jpackage.internal.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class TokenReplaceTest { + + public record TestSpec(String str, Optional expectedStr, Optional expectedCtorException, + Optional expectedApplyToException, Map tokenWithValues, boolean recursive) { + + public TestSpec { + Objects.requireNonNull(expectedStr); + Objects.requireNonNull(expectedCtorException); + Objects.requireNonNull(expectedApplyToException); + Objects.requireNonNull(tokenWithValues); + tokenWithValues.values().forEach(Objects::requireNonNull); + + if (expectedStr.isPresent()) { + if (!(expectedCtorException.isEmpty() && expectedApplyToException.isEmpty())) { + throw new IllegalArgumentException(); + } + } else if (expectedCtorException.isEmpty() == expectedApplyToException.isEmpty()) { + throw new IllegalArgumentException(); + } + } + + static final class Builder { + + Builder str(String v) { + str = v; + return this; + } + + Builder recursive(boolean v) { + recursive = v; + return this; + } + + Builder recursive() { + return recursive(true); + } + + Builder expect(String v) { + expectedStr = v; + return this; + } + + Builder expectCtorThrow(String v) { + expectedCtorException = new IllegalArgumentException(v); + return this; + } + + Builder expectApplyToNPE() { + expectedApplyToException = new NullPointerException(); + return this; + } + + Builder expectInfiniteRecursion() { + expectedApplyToException = new IllegalStateException("Infinite recursion"); + return this; + } + + Builder token(String token, String value) { + tokenWithValues.put(token, value); + return this; + } + + TestSpec create() { + return new TestSpec(str, expectedStr(), Optional.ofNullable(expectedCtorException), + Optional.ofNullable(expectedApplyToException), tokenWithValues, recursive); + } + + private Optional expectedStr() { + if (expectedCtorException == null && expectedApplyToException == null) { + return Optional.ofNullable(expectedStr).or(() -> Optional.of(str)); + } else { + return Optional.empty(); + } + } + + private boolean recursive; + private String str; + private String expectedStr; + private Exception expectedCtorException; + private Exception expectedApplyToException; + private final Map tokenWithValues = new HashMap<>(); + } + + void test() { + final var tokens = tokenWithValues.keySet().toArray(String[]::new); + expectedStr.ifPresent(expected -> { + final var tokenReplace = new TokenReplace(tokens); + final String actual; + if (recursive) { + actual = tokenReplace.recursiveApplyTo(str, tokenWithValues::get); + } else { + actual = tokenReplace.applyTo(str, tokenWithValues::get); + } + assertEquals(expected, actual); + }); + + expectedCtorException.ifPresent(expected -> { + final var ex = assertThrows(expected.getClass(), () -> { + new TokenReplace(tokens); + }); + assertEquals(expected.getMessage(), ex.getMessage()); + }); + + expectedApplyToException.ifPresent(expected -> { + final var tokenReplace = new TokenReplace(tokens); + final var ex = assertThrows(expected.getClass(), () -> { + if (recursive) { + tokenReplace.recursiveApplyTo(str, tokenWithValues::get); + } else { + tokenReplace.applyTo(str, tokenWithValues::get); + } + }); + assertEquals(expected.getMessage(), ex.getMessage()); + }); + } + } + + @ParameterizedTest + @MethodSource + public void test(TestSpec spec) { + spec.test(); + } + + public static Stream test() { + return Stream.of( + testSpec("foo").token("", "B").expectCtorThrow("Empty token in the list of tokens"), + testSpec("foo").expectCtorThrow("Empty token list"), + testSpec("a").expect("a").token("b", "B"), + testSpec("a").expect("A").token("a", "A"), + testSpec("aaa").expect("AAA").token("a", "A"), + testSpec("aaa").recursive().expect("{B}{B}{B}").token("a", "b").token("b", "{B}"), + testSpec("aaa").token("a", "aa").token("aa", "C").expect("Caa"), + testSpec("aaa").token("a", "aa").token("aa", "C").expect("CC").recursive(), + testSpec("aaa").expect("A2A").token("a", "A").token("aa", "A2"), + testSpec("aaa").token("a", "b").token("b", "c").token("c", "a").expect("bbb"), + testSpec("aaa").token("a", "b").token("b", "").recursive().expect(""), + testSpec("aaa").token("a", "").recursive().expect(""), + testSpec("aaa").token("a", "b").token("b", "c").token("c", "a").expectInfiniteRecursion().recursive(), + testSpec(null).token("a", "b").expectApplyToNPE(), + testSpec("abc").expect("abc").token(".", "A"), + testSpec("abc.").expect("abcD").token(".", "D") + ).map(TestSpec.Builder::create); + } + + private static final class CountingSupplier implements Supplier { + + CountingSupplier(Object value, int expectedCount) { + this.value = value; + this.expectedCount = expectedCount; + } + + @Override + public Object get() { + counter++; + return value; + } + + public Object value() { + return value; + } + + void verifyCount() { + assertEquals(expectedCount, counter); + } + + private final Object value; + private int counter; + private final int expectedCount; + } + + @Test + public void testCombine() { + final var x = new TokenReplace("a"); + final var y = new TokenReplace("aa"); + + final var xy = TokenReplace.combine(x, y); + + assertEquals(xy, new TokenReplace("aa", "a")); + assertEquals(xy, new TokenReplace("a", "aa")); + } + + @Test + public void testCombine2() { + final var x = new TokenReplace("a"); + final var y = new TokenReplace("a"); + + final var xy = TokenReplace.combine(x, y); + + assertEquals(xy, new TokenReplace("a", "a")); + assertEquals(xy, new TokenReplace("a")); + assertEquals(xy, x); + assertEquals(xy, y); + } + + @Test + public void testCombine3() { + final var x = new TokenReplace("a"); + final var y = new TokenReplace("b"); + + final var xy = TokenReplace.combine(x, y); + + assertEquals(xy, new TokenReplace("a", "b")); + assertEquals(xy, new TokenReplace("b", "a")); + } + + @Test + public void testEquals() { + final var x = new TokenReplace("x"); + final var y = new TokenReplace("y"); + final var y2 = new TokenReplace("y"); + + assertNotEquals(x, y); + assertNotEquals(x, null); + assertNotEquals(null, x); + assertNotEquals(x, "x"); + + assertEquals(y, y2); + assertEquals(y, y); + } + + @Test + public void testCreateCachingTokenValueSupplier() { + final var neverCalledSupplier = new CountingSupplier("", 0); + final var calledOnceSupplier = new CountingSupplier("foo", 1); + final var calledOnceNullSupplier = new CountingSupplier(null, 1); + + final var supplier = TokenReplace.createCachingTokenValueSupplier(Map.of( + "never", neverCalledSupplier, + "once", calledOnceSupplier, + "onceNull", calledOnceNullSupplier + )); + + for (int i = 0; i != 2; i++) { + assertEquals(calledOnceSupplier.value(), supplier.apply("once")); + + final var ex = assertThrows(NullPointerException.class, () -> supplier.apply("onceNull")); + assertEquals("Null value for token [onceNull]", ex.getMessage()); + } + + final var ex = assertThrows(NullPointerException.class, () -> supplier.apply("foo")); + assertEquals("No token value supplier for token [foo]", ex.getMessage()); + + neverCalledSupplier.verifyCount(); + calledOnceSupplier.verifyCount(); + calledOnceNullSupplier.verifyCount(); + } + + private static TestSpec.Builder testSpec(String str) { + return new TestSpec.Builder().str(str); + } +} diff --git a/test/jdk/tools/jpackage/share/AppLauncherSubstTest.java b/test/jdk/tools/jpackage/share/AppLauncherSubstTest.java new file mode 100644 index 0000000000000..3e111cafb1319 --- /dev/null +++ b/test/jdk/tools/jpackage/share/AppLauncherSubstTest.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import static jdk.jpackage.test.HelloApp.configureAndExecute; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import jdk.jpackage.internal.util.TokenReplace; +import jdk.jpackage.test.Annotations.ParameterSupplier; +import jdk.jpackage.test.Annotations.Test; +import jdk.jpackage.test.Executor; +import jdk.jpackage.test.JPackageCommand; +import jdk.jpackage.test.JPackageCommand.Macro; +import jdk.jpackage.test.TKit; + +/* + * @test + * @summary Tests environment variables substitution by jpackage launcher + * @bug 8341641 + * @library /test/jdk/tools/jpackage/helpers + * @build jdk.jpackage.test.* + * @build AppLauncherSubstTest + * @run main/othervm -Xmx512m jdk.jpackage.test.Main + * --jpt-run=AppLauncherSubstTest + */ +public class AppLauncherSubstTest { + + record TestSpec(String str, String expectedStr, Map env) { + + static class Builder { + + Builder str(String v) { + str = v; + return this; + } + + Builder expect(String v) { + expectedStr = v; + return this; + } + + Builder var(String name, String value) { + env.put(name, value); + return this; + } + + TestSpec create() { + return new TestSpec(str, Optional.ofNullable(expectedStr).orElse(str), env); + } + + private String str; + private String expectedStr; + private Map env = new HashMap<>(); + } + + public TestSpec { + Objects.requireNonNull(str); + Objects.requireNonNull(expectedStr); + Objects.requireNonNull(env); + env.entrySet().forEach(Objects::requireNonNull); + } + + public String resolveExpectedStr(JPackageCommand cmd) { + return MACROS.applyTo(expectedStr, token -> { + // @@APPDIR@@ -> APPDIR + final var macro = token.substring(2, token.length() - 2); + return Path.of(cmd.macroValue(Macro.valueOf(macro))).toAbsolutePath(); + }); + } + + @Override + public String toString() { + final var sb = new StringBuilder(); + sb.append("str=").append(str); + sb.append(", expect=").append(expectedStr); + if (!env.isEmpty()) { + sb.append(", env=").append(env); + } + return sb.toString(); + } + + private final static TokenReplace MACROS = new TokenReplace(Stream.of(Macro.values()).map(macro -> { + return String.format("@@%s@@", macro); + }).toArray(String[]::new)); + } + + @Test + @ParameterSupplier + public static void test(TestSpec spec) throws IOException { + final var cmd = JPackageCommand.helloAppImage(TEST_APP_JAVA + "*Hello") + .ignoreFakeRuntime() + .setArgumentValue("--java-options", "-D" + TEST_PROP + "="); + + cmd.execute(); + + // Manually edit main launcher config file. Don't do it directly because + // jpackage doesn't pass the value of `--java-options` option as-is into the config file. + final var cfgFile = cmd.appLauncherCfgPath(null); + TKit.createTextFile(cfgFile, Files.readAllLines(cfgFile).stream().map(line -> { + return TEST_PROP_REGEXP.matcher(line).replaceFirst(Matcher.quoteReplacement(spec.str())); + })); + + final var launcherExec = new Executor() + .saveOutput() + .dumpOutput() + .setExecutable(cmd.appLauncherPath().toAbsolutePath()) + .addArguments("--print-sys-prop=" + TEST_PROP); + + spec.env().forEach(launcherExec::setEnvVar); + + final var resolvedExpectedStr = spec.resolveExpectedStr(cmd); + final var actualStr = configureAndExecute(0, launcherExec).getFirstLineOfOutput().substring((TEST_PROP + "=").length()); + + if (TKit.isWindows() && !resolvedExpectedStr.equals(spec.expectedStr())) { + TKit.assertEquals(resolvedExpectedStr.toLowerCase(), actualStr.toLowerCase(), "Check the property value is as expected [lowercase]"); + } else { + TKit.assertEquals(resolvedExpectedStr, actualStr, "Check the property value is as expected"); + } + } + + public static Collection test() { + return Stream.of( + testSpec(""), + testSpec("$ONE ${TWO} ${ONE} $TWO ONE TWO").expect("one two one two ONE TWO").var("ONE", "one").var("TWO", "two"), + testSpec("\\$FOO\\\\$FOO\\${FOO}\\\\${FOO}").expect("$FOO\\BAR${FOO}\\BAR").var("FOO", "BAR"), + testSpec("$FOO-$BAR").expect("$FOO-").var("BAR", ""), + testSpec("${BINDIR}${APPDIR}${ROOTDIR}").expect("@@BINDIR@@@@APPDIR@@@@ROOTDIR@@").var("BINDIR", "a").var("APPDIR", "b").var("ROOTDIR", "c"), + testSpec("$BINDIR$APPDIR$ROOTDIR").expect("@@BINDIR@@@@APPDIR@@@@ROOTDIR@@"), + testSpec("$BINDIR2$APPDIR2$ROOTDIR2").expect("$BINDIR2$APPDIR2$ROOTDIR2") + ).map(TestSpec.Builder::create).map(v -> { + return new Object[] {v}; + }).toList(); + } + + private static TestSpec.Builder testSpec(String str) { + return new TestSpec.Builder().str(str); + } + + private static final Path TEST_APP_JAVA = TKit.TEST_SRC_ROOT.resolve("apps/PrintEnv.java"); + + private static final String TEST_PROP = "jdk.jpackage.test.Property"; + private static final Pattern TEST_PROP_REGEXP = Pattern.compile("(?<=" + Pattern.quote(TEST_PROP) + "=).*"); +}