blob: e8f0eef5e6e2d2485a58c85167ca6d2ee2263020 [file]
/*
* Copyright (c) 2024, 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;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.xml.XMLConstants;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stax.StAXResult;
import javax.xml.transform.stream.StreamSource;
import jdk.jpackage.internal.WixToolset.WixToolsetType;
import jdk.jpackage.internal.resources.ResourceLocator;
import jdk.jpackage.internal.util.XmlUtils;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;
/**
* Converts WiX v3 source file into WiX v4 format.
*/
final class WixSourceConverter {
enum Status {
SavedAsIs,
SavedAsIsMalfromedXml,
Transformed,
}
WixSourceConverter(Path resourceDir) throws IOException {
var buf = new ByteArrayOutputStream();
new OverridableResource("wix3-to-wix4-conv.xsl", ResourceLocator.class)
.setPublicName("wix-conv.xsl")
.setResourceDir(resourceDir)
.setCategory(I18N.getString("resource.wix-src-conv"))
.saveToStream(buf);
var xslt = new StreamSource(new ByteArrayInputStream(buf.toByteArray()));
var tf = TransformerFactory.newInstance();
try {
this.transformer = tf.newTransformer(xslt);
} catch (TransformerException ex) {
// Should never happen
throw new RuntimeException(ex);
}
this.outputFactory = XMLOutputFactory.newInstance();
}
Status appyTo(OverridableResource resource, Path resourceSaveAsFile) throws IOException {
// Save the resource into DOM tree and read xml namespaces from it.
// If some namespaces are not recognized by this converter, save the resource as is.
// If all detected namespaces are recognized, run transformation of the DOM tree and save
// output into destination file.
var buf = saveResourceInMemory(resource);
Document inputXmlDom;
try {
inputXmlDom = XmlUtils.initDocumentBuilder().parse(new ByteArrayInputStream(buf));
} catch (SAXException ex) {
// Malformed XML, don't run converter, save as is.
resource.saveToFile(resourceSaveAsFile);
return Status.SavedAsIsMalfromedXml;
}
try {
var nc = new NamespaceCollector();
TransformerFactory.newInstance().newTransformer().
transform(new DOMSource(inputXmlDom), new StAXResult((XMLStreamWriter) Proxy.
newProxyInstance(XMLStreamWriter.class.getClassLoader(),
new Class<?>[]{XMLStreamWriter.class}, nc)));
if (!nc.isOnlyKnownNamespacesUsed()) {
// Unsupported namespaces detected in input XML, don't run converter, save as is.
resource.saveToFile(resourceSaveAsFile);
return Status.SavedAsIs;
}
} catch (TransformerException ex) {
// Should never happen
throw new RuntimeException(ex);
}
Supplier<Source> inputXml = () -> {
// Should be "new DOMSource(inputXmlDom)", but no transfromation is applied in this case!
return new StreamSource(new ByteArrayInputStream(buf));
};
var nc = new NamespaceCollector();
try {
// Run transfomation to collect namespaces from the output XML.
transformer.transform(inputXml.get(), new StAXResult((XMLStreamWriter) Proxy.
newProxyInstance(XMLStreamWriter.class.getClassLoader(),
new Class<?>[]{XMLStreamWriter.class}, nc)));
} catch (TransformerException ex) {
// Should never happen
throw new RuntimeException(ex);
}
try (var outXml = new ByteArrayOutputStream()) {
transformer.transform(inputXml.get(), new StAXResult((XMLStreamWriter) Proxy.
newProxyInstance(XMLStreamWriter.class.getClassLoader(),
new Class<?>[]{XMLStreamWriter.class}, new NamespaceCleaner(nc.
getPrefixToUri(), outputFactory.createXMLStreamWriter(outXml)))));
Files.createDirectories(IOUtils.getParent(resourceSaveAsFile));
Files.copy(new ByteArrayInputStream(outXml.toByteArray()), resourceSaveAsFile,
StandardCopyOption.REPLACE_EXISTING);
} catch (TransformerException | XMLStreamException ex) {
// Should never happen
throw new RuntimeException(ex);
}
return Status.Transformed;
}
private static byte[] saveResourceInMemory(OverridableResource resource) throws IOException {
var buf = new ByteArrayOutputStream();
resource.saveToStream(buf);
return buf.toByteArray();
}
static final class ResourceGroup {
ResourceGroup(WixToolsetType wixToolsetType) {
this.wixToolsetType = wixToolsetType;
}
void addResource(OverridableResource resource, Path resourceSaveAsFile) {
resources.put(resourceSaveAsFile, resource);
}
void saveResources() throws IOException {
switch (wixToolsetType) {
case Wix3 -> {
for (var e : resources.entrySet()) {
e.getValue().saveToFile(e.getKey());
}
}
case Wix4 -> {
var resourceDir = resources.values().stream().filter(res -> {
return null != res.getResourceDir();
}).findAny().map(OverridableResource::getResourceDir).orElse(null);
var conv = new WixSourceConverter(resourceDir);
for (var e : resources.entrySet()) {
conv.appyTo(e.getValue(), e.getKey());
}
}
default -> {
throw new IllegalArgumentException();
}
}
}
private final Map<Path, OverridableResource> resources = new HashMap<>();
private final WixToolsetType wixToolsetType;
}
//
// Default JDK XSLT v1.0 processor is not handling well default namespace mappings.
// Running generic template:
//
// <xsl:template match="wix3loc:*">
// <xsl:element name="{local-name()}" namespace="http://wixtoolset.org/schemas/v4/wxl">
// <xsl:apply-templates select="@*|node()"/>
// </xsl:element>
// </xsl:template>
//
// produces:
//
// <ns0:WixLocalization xmlns:ns0="http://wixtoolset.org/schemas/v4/wxl" Culture="en-us" Codepage="1252">
// <ns1:String xmlns:ns1="http://wixtoolset.org/schemas/v4/wxl" Value="The folder [INSTALLDIR] already exist. Would you like to install to that folder anyway?" Id="message.install.dir.exist"/>
// <ns2:String xmlns:ns2="http://wixtoolset.org/schemas/v4/wxl" Value="Main Feature" Id="MainFeatureTitle"/>
// ...
// <ns12:String xmlns:ns12="http://wixtoolset.org/schemas/v4/wxl" Value="Open with [ProductName]" Id="ContextMenuCommandLabel"/>
// </ns0:WixLocalization>
//
// which is conformant XML but WiX4 doesn't like it:
//
// wix.exe : error WIX0202: The {http://wixtoolset.org/schemas/v4/wxl}String element contains an unsupported extension attribute '{http://www.w3.org/2000/xmlns/}ns1'. The {http://wixtoolset.org/schemas/v4/wxl}String element does not currently support extension attributes. Is the {http://www.w3.org/2000/xmlns/}ns1 attribute using the correct XML namespace?
// wix.exe : error WIX0202: The {http://wixtoolset.org/schemas/v4/wxl}String element contains an unsupported extension attribute '{http://www.w3.org/2000/xmlns/}ns2'. The {http://wixtoolset.org/schemas/v4/wxl}String element does not currently support extension attributes. Is the {http://www.w3.org/2000/xmlns/}ns2 attribute using the correct XML namespace?
// wix.exe : error WIX0202: The {http://wixtoolset.org/schemas/v4/wxl}String element contains an unsupported extension attribute '{http://www.w3.org/2000/xmlns/}ns3'. The {http://wixtoolset.org/schemas/v4/wxl}String element does not currently support extension attributes. Is the {http://www.w3.org/2000/xmlns/}ns3 attribute using the correct XML namespace?
//
// Someone hit this issue long ago - https://stackoverflow.com/questions/26904623/replace-default-namespace-using-xsl and they suggested to use different XSLT processor.
// Two online XSLT processors used in testing produce clean XML with this template indeed:
//
// <WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Codepage="1252" Culture="en-us">
// <String Value="The folder [INSTALLDIR] already exist. Would you like to install to that folder anyway?" Id="message.install.dir.exist"/>
// <String Value="Main Feature" Id="MainFeatureTitle"/>
// ...
// <String Value="Open with [ProductName]" Id="ContextMenuCommandLabel"/>
// </WixLocalization>
//
// To workaround default JDK's XSLT processor limitations we do additionl postprocessing of output XML with NamespaceCleaner class.
//
private static class NamespaceCleaner implements InvocationHandler {
NamespaceCleaner(Map<String, String> prefixToUri, XMLStreamWriter target) {
this.uriToPrefix = prefixToUri.entrySet().stream().collect(Collectors.toMap(
Map.Entry::getValue, e -> {
return new Prefix(e.getKey());
}, (x, y) -> x));
this.prefixToUri = prefixToUri;
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
switch (method.getName()) {
case "writeNamespace" -> {
final String uri = (String) args[1];
var prefixObj = uriToPrefix.get(uri);
if (!prefixObj.written) {
prefixObj.written = true;
target.writeNamespace(prefixObj.name, uri);
}
return null;
}
case "writeStartElement", "writeEmptyElement" -> {
final String name;
switch (args.length) {
case 1 ->
name = (String) args[0];
case 2, 3 ->
name = (String) args[1];
default ->
throw new IllegalArgumentException();
}
final String prefix;
final String localName;
final String[] tokens = name.split(":", 2);
if (tokens.length == 2) {
prefix = tokens[0];
localName = tokens[1];
} else {
localName = name;
switch (args.length) {
case 3 ->
prefix = (String) args[0];
case 2 ->
prefix = uriToPrefix.get((String) args[0]).name;
default ->
prefix = null;
}
}
if (prefix != null && !XMLConstants.DEFAULT_NS_PREFIX.equals(prefix)) {
final String uri = prefixToUri.get(prefix);
var prefixObj = uriToPrefix.get(uri);
if (prefixObj.written) {
var writeName = String.join(":", prefixObj.name, localName);
if ("writeStartElement".equals(method.getName())) {
target.writeStartElement(writeName);
} else {
target.writeEmptyElement(writeName);
}
return null;
} else {
prefixObj.written = (args.length > 1);
args = Arrays.copyOf(args, args.length, Object[].class);
if (localName.equals(name)) {
// No prefix in the name
if (args.length == 3) {
args[0] = prefixObj.name;
}
} else {
var writeName = String.join(":", prefixObj.name, localName);
switch (args.length) {
case 1 ->
args[0] = writeName;
case 2 -> {
args[0] = uri;
args[1] = writeName;
}
case 3 -> {
args[0] = prefixObj.name;
args[1] = writeName;
args[2] = uri;
}
}
}
}
}
}
}
return method.invoke(target, args);
}
static class Prefix {
Prefix(String name) {
this.name = name;
}
private final String name;
private boolean written;
}
private final Map<String, Prefix> uriToPrefix;
private final Map<String, String> prefixToUri;
private final XMLStreamWriter target;
}
private static class NamespaceCollector implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
switch (method.getName()) {
case "setPrefix", "writeNamespace" -> {
var prefix = (String) args[0];
var namespace = prefixToUri.computeIfAbsent(prefix, k -> createValue(args[1]));
if (XMLConstants.XMLNS_ATTRIBUTE.equals(prefix)) {
namespace.setValue(true);
}
}
case "writeStartElement", "writeEmptyElement" -> {
switch (args.length) {
case 3 ->
prefixToUri.computeIfAbsent((String) args[0], k -> createValue(
(String) args[2])).setValue(true);
case 2 ->
initFromElementName((String) args[1], (String) args[0]);
case 1 ->
initFromElementName((String) args[0], null);
}
}
}
return null;
}
boolean isOnlyKnownNamespacesUsed() {
return prefixToUri.values().stream().filter(namespace -> {
return namespace.getValue();
}).allMatch(namespace -> {
if (!namespace.getValue()) {
return true;
} else {
return KNOWN_NAMESPACES.contains(namespace.getKey());
}
});
}
Map<String, String> getPrefixToUri() {
return prefixToUri.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
e -> {
return e.getValue().getKey();
}));
}
private void initFromElementName(String name, String namespace) {
final String[] tokens = name.split(":", 2);
if (tokens.length == 2) {
if (namespace != null) {
prefixToUri.computeIfAbsent(tokens[0], k -> createValue(namespace)).setValue(
true);
} else {
prefixToUri.computeIfPresent(tokens[0], (k, v) -> {
v.setValue(true);
return v;
});
}
}
}
private Map.Entry<String, Boolean> createValue(Object prefix) {
return new AbstractMap.SimpleEntry<String, Boolean>((String) prefix, false);
}
private final Map<String, Map.Entry<String, Boolean>> prefixToUri = new HashMap<>();
}
private final Transformer transformer;
private final XMLOutputFactory outputFactory;
// The list of WiX v3 namespaces this converter can handle
private static final Set<String> KNOWN_NAMESPACES = Set.of(
"http://schemas.microsoft.com/wix/2006/localization",
"http://schemas.microsoft.com/wix/2006/wi");
}