View Javadoc
1   /*
2    * #%L
3    * wcm.io
4    * %%
5    * Copyright (C) 2014 wcm.io
6    * %%
7    * Licensed under the Apache License, Version 2.0 (the "License");
8    * you may not use this file except in compliance with the License.
9    * You may obtain a copy of the License at
10   *
11   *      http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the License is distributed on an "AS IS" BASIS,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the License for the specific language governing permissions and
17   * limitations under the License.
18   * #L%
19   */
20  package io.wcm.tooling.commons.packmgr.unpack;
21  
22  import static org.apache.jackrabbit.vault.util.Constants.DOT_CONTENT_XML;
23  import static org.apache.jackrabbit.vault.util.Constants.ROOT_DIR;
24  
25  import java.io.File;
26  import java.io.IOException;
27  import java.io.InputStream;
28  import java.io.OutputStream;
29  import java.nio.file.Files;
30  import java.nio.file.Path;
31  import java.util.ArrayList;
32  import java.util.Calendar;
33  import java.util.Enumeration;
34  import java.util.HashSet;
35  import java.util.LinkedHashSet;
36  import java.util.List;
37  import java.util.Set;
38  import java.util.TreeSet;
39  import java.util.concurrent.atomic.AtomicBoolean;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  import java.util.regex.PatternSyntaxException;
43  
44  import javax.jcr.PropertyType;
45  import javax.xml.XMLConstants;
46  import javax.xml.parsers.ParserConfigurationException;
47  import javax.xml.parsers.SAXParser;
48  import javax.xml.parsers.SAXParserFactory;
49  
50  import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
51  import org.apache.commons.compress.archivers.zip.ZipFile;
52  import org.apache.commons.io.FilenameUtils;
53  import org.apache.commons.lang3.StringUtils;
54  import org.apache.commons.lang3.Strings;
55  import org.apache.jackrabbit.JcrConstants;
56  import org.apache.jackrabbit.util.ISO8601;
57  import org.apache.jackrabbit.vault.fs.io.DocViewFormat;
58  import org.apache.jackrabbit.vault.util.PlatformNameFormat;
59  import org.jdom2.Attribute;
60  import org.jdom2.Document;
61  import org.jdom2.Element;
62  import org.jdom2.JDOMException;
63  import org.jdom2.Namespace;
64  import org.jdom2.input.SAXBuilder;
65  import org.jdom2.output.Format;
66  import org.jdom2.output.LineSeparator;
67  import org.jdom2.output.XMLOutputter;
68  import org.jetbrains.annotations.Nullable;
69  import org.xml.sax.Attributes;
70  import org.xml.sax.SAXException;
71  import org.xml.sax.helpers.DefaultHandler;
72  
73  import io.wcm.tooling.commons.packmgr.PackageManagerException;
74  
75  /**
76   * Manages unpacking ZIP file content applying exclude patterns.
77   */
78  public final class ContentUnpacker {
79  
80    private static final String MIXINS_PROPERTY = "jcr:mixinTypes";
81    private static final String PRIMARYTYPE_PROPERTY = "jcr:primaryType";
82    private static final Namespace JCR_NAMESPACE = Namespace.getNamespace("jcr", "http://www.jcp.org/jcr/1.0");
83    private static final Namespace CQ_NAMESPACE = Namespace.getNamespace("cq", "http://www.day.com/jcr/cq/1.0");
84    private static final Pattern FILENAME_NAMESPACE_PATTERN = Pattern.compile("^([^:]+):(.+)$");
85  
86    private static final SAXParserFactory SAX_PARSER_FACTORY;
87    static {
88      SAX_PARSER_FACTORY = SAXParserFactory.newInstance();
89      SAX_PARSER_FACTORY.setNamespaceAware(true);
90    }
91  
92    private static final DocViewFormat DOCVIEWFORMAT = new DocViewFormat();
93  
94    private final Pattern[] excludeFiles;
95    private final Pattern[] excludeNodes;
96    private final Pattern[] excludeProperties;
97    private final Pattern[] excludeMixins;
98    private final boolean markReplicationActivated;
99    private final Pattern[] markReplicationActivatedIncludeNodes;
100   private final String dateLastReplicated;
101 
102   /**
103    * Constructor.
104    * @param properties Configuration properties
105    */
106   public ContentUnpacker(ContentUnpackerProperties properties) {
107     this.excludeFiles = toPatternArray(properties.getExcludeFiles());
108     this.excludeNodes = toPatternArray(properties.getExcludeNodes());
109     this.excludeProperties = toPatternArray(properties.getExcludeProperties());
110     this.excludeMixins = toPatternArray(properties.getExcludeMixins());
111     this.markReplicationActivated = properties.isMarkReplicationActivated();
112     this.markReplicationActivatedIncludeNodes = toPatternArray(properties.getMarkReplicationActivatedIncludeNodes());
113 
114     if (StringUtils.isNotBlank(properties.getDateLastReplicated())) {
115       this.dateLastReplicated = properties.getDateLastReplicated();
116     }
117     else {
118       // set to current date
119       Calendar cal = Calendar.getInstance();
120       cal.set(Calendar.HOUR_OF_DAY, 0);
121       cal.set(Calendar.MINUTE, 0);
122       cal.set(Calendar.SECOND, 0);
123       cal.set(Calendar.MILLISECOND, 0);
124       this.dateLastReplicated = ISO8601.format(cal);
125     }
126   }
127 
128   private static Pattern[] toPatternArray(String[] patternStrings) {
129     if (patternStrings == null) {
130       return new Pattern[0];
131     }
132     Pattern[] patterns = new Pattern[patternStrings.length];
133     for (int i = 0; i < patternStrings.length; i++) {
134       try {
135         patterns[i] = Pattern.compile(patternStrings[i]);
136       }
137       catch (PatternSyntaxException ex) {
138         throw new PackageManagerException("Invalid regexp pattern: " + patternStrings[i], ex);
139       }
140     }
141     return patterns;
142   }
143 
144   private static boolean matches(String name, Pattern[] patterns, boolean defaultIfNotPatternsDefined) {
145     if (patterns.length == 0) {
146       return defaultIfNotPatternsDefined;
147     }
148     for (Pattern pattern : patterns) {
149       if (pattern.matcher(name).matches()) {
150         return true;
151       }
152     }
153     return false;
154   }
155 
156   private boolean applyXmlExcludes(String name) {
157     if (this.excludeNodes.length == 0 && this.excludeProperties.length == 0) {
158       return false;
159     }
160     return isJcrContentXmlFile(name);
161   }
162 
163   private boolean isJcrContentXmlFile(String name) {
164     return Strings.CI.equals(FilenameUtils.getExtension(name), "xml")
165         && Strings.CS.startsWith(name, "jcr_root/");
166   }
167 
168   /**
169    * Unpacks file
170    * @param file File
171    * @param outputDirectory Output directory
172    */
173   public void unpack(File file, File outputDirectory) {
174     Path outputDirectoryPath = outputDirectory.toPath();
175     long entryCount = 0;
176     long totalBytes = 0;
177     try (ZipFile zipFile = new ZipFile.Builder().setFile(file).get()) {
178       Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
179       while (entries.hasMoreElements()) {
180         ZipArchiveEntry entry = entries.nextElement();
181         if (!matches(entry.getName(), excludeFiles, false)) {
182           entryCount++;
183           SafeExtract.checkEntryCount(entryCount);
184           totalBytes = unpackEntry(zipFile, entry, outputDirectoryPath, totalBytes);
185         }
186       }
187     }
188     catch (IOException ex) {
189       throw new PackageManagerException("Error reading content package " + file.getAbsolutePath(), ex);
190     }
191   }
192 
193   @SuppressWarnings("java:S3776") // complexity
194   private long unpackEntry(ZipFile zipFile, ZipArchiveEntry entry, Path outputDirectory, long bytesWrittenSoFar) throws IOException {
195     // resolve safely against the base directory (mitigates zip slip)
196     Path entryPath = SafeExtract.resolveSafely(outputDirectory, entry.getName());
197     if (entry.isDirectory()) {
198       Files.createDirectories(entryPath);
199       return bytesWrittenSoFar;
200     }
201     else {
202       Set<String> namespacePrefixes = null;
203       if (applyXmlExcludes(entry.getName())) {
204         namespacePrefixes = getNamespacePrefixes(zipFile, entry);
205       }
206 
207       long totalBytes = bytesWrittenSoFar;
208       try (InputStream entryStream = zipFile.getInputStream(entry)) {
209         Files.deleteIfExists(entryPath);
210         Path directory = entryPath.getParent();
211         if (directory != null) {
212           Files.createDirectories(directory);
213         }
214 
215         try (OutputStream fos = Files.newOutputStream(entryPath)) {
216           if (applyXmlExcludes(entry.getName()) && namespacePrefixes != null) {
217             // write file with XML filtering (size limit not enforced for filtered XML, but XML files are small)
218             try {
219               writeXmlWithExcludes(entry, entryStream, fos, namespacePrefixes);
220             }
221             catch (JDOMException ex) {
222               throw new PackageManagerException("Unable to parse XML file: " + entry.getName(), ex);
223             }
224           }
225           else {
226             // write file directly without XML filtering, enforce size limit (mitigates zip bomb)
227             totalBytes = SafeExtract.copyWithLimit(entryStream, fos, totalBytes);
228           }
229         }
230         if (isJcrContentXmlFile(entry.getName())) {
231           // format output file using DocView format
232           File outputFile = entryPath.toFile();
233           try {
234             DOCVIEWFORMAT.format(outputFile, false);
235           }
236           catch (IOException ex) {
237             throw new IOException("Unable to apply DocView format to file: " + outputFile.getAbsolutePath(), ex);
238           }
239         }
240       }
241       return totalBytes;
242     }
243   }
244 
245   /**
246    * Parses XML file with namespace-aware SAX parser to get defined namespaces prefixes in order of appearance
247    * (to keep the same order when outputting the XML file again).
248    * @param zipFile ZIP file
249    * @param entry ZIP entry
250    * @return Ordered set with namespace prefixes in correct order.
251    *         Returns null if given XML file does not contain FileVault XML content.
252    */
253   private @Nullable Set<String> getNamespacePrefixes(ZipFile zipFile, ZipArchiveEntry entry) throws IOException {
254     try (InputStream entryStream = zipFile.getInputStream(entry)) {
255       SAXParser parser = SAX_PARSER_FACTORY.newSAXParser();
256       final Set<String> prefixes = new LinkedHashSet<>();
257 
258       final AtomicBoolean foundRootElement = new AtomicBoolean(false);
259       DefaultHandler handler = new DefaultHandler() {
260 
261         @Override
262         public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
263           // validate that XML file contains FileVault XML content
264           if (Strings.CS.equals(uri, JCR_NAMESPACE.getURI()) && Strings.CS.equals(localName, "root")) {
265             foundRootElement.set(true);
266           }
267         }
268 
269         @Override
270         public void startPrefixMapping(String prefix, String uri) throws SAXException {
271           if (StringUtils.isNotBlank(prefix)) {
272             prefixes.add(prefix);
273           }
274         }
275       };
276       parser.parse(entryStream, handler);
277 
278       if (!foundRootElement.get()) {
279         return null;
280       }
281       else {
282         return prefixes;
283       }
284     }
285     catch (IOException | SAXException | ParserConfigurationException ex) {
286       throw new IOException("Error parsing " + entry.getName(), ex);
287     }
288   }
289 
290   private void writeXmlWithExcludes(ZipArchiveEntry entry, InputStream inputStream, OutputStream outputStream, Set<String> namespacePrefixes)
291       throws IOException, JDOMException {
292     SAXBuilder saxBuilder = new SAXBuilder();
293     saxBuilder.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
294     saxBuilder.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
295     Document doc = saxBuilder.build(inputStream);
296 
297     Set<String> namespacePrefixesActuallyUsed = new HashSet<>();
298 
299     // check for namespace prefix in file name
300     String namespacePrefix = getNamespacePrefix(entry.getName());
301     if (namespacePrefix != null) {
302       namespacePrefixesActuallyUsed.add(namespacePrefix);
303     }
304 
305     applyXmlExcludes(doc.getRootElement(), getParentPath(entry), namespacePrefixesActuallyUsed, false);
306 
307     XMLOutputter outputter = new XMLOutputter(Format.getPrettyFormat()
308       .setIndent("    ")
309       .setLineSeparator(LineSeparator.UNIX));
310     outputter.setXMLOutputProcessor(new NamspaceOrderedXmlProcessor(namespacePrefixes, namespacePrefixesActuallyUsed));
311     outputter.output(doc, outputStream);
312     outputStream.flush();
313   }
314 
315   static String getNamespacePrefix(String path) {
316     String fileName = FilenameUtils.getName(path);
317     if (Strings.CS.equals(DOT_CONTENT_XML, fileName)) {
318       String parentFolderName = FilenameUtils.getName(FilenameUtils.getPathNoEndSeparator(path));
319       if (parentFolderName != null) {
320         String nodeName = PlatformNameFormat.getRepositoryName(parentFolderName);
321         Matcher matcher = FILENAME_NAMESPACE_PATTERN.matcher(nodeName);
322         if (matcher.matches()) {
323           return matcher.group(1);
324         }
325       }
326     }
327     return null;
328   }
329 
330   private String getParentPath(ZipArchiveEntry entry) {
331     return Strings.CS.removeEnd(Strings.CS.removeStart(entry.getName(), ROOT_DIR), "/" + DOT_CONTENT_XML);
332   }
333 
334   private String buildElementPath(Element element, String parentPath) {
335     StringBuilder path = new StringBuilder(parentPath);
336     if (!Strings.CS.equals(element.getQualifiedName(), "jcr:root")) {
337       path.append("/").append(element.getQualifiedName());
338     }
339     return path.toString();
340   }
341 
342   @SuppressWarnings({
343       "PMD.EmptyControlStatement",
344       "java:S3776", "java:S6541" // complexity
345   })
346   private void applyXmlExcludes(Element element, String parentPath, Set<String> namespacePrefixesActuallyUsed,
347       boolean insideReplicationElement) {
348     String path = buildElementPath(element, parentPath);
349     if (matches(path, this.excludeNodes, false)) {
350       element.detach();
351       return;
352     }
353     collectNamespacePrefix(namespacePrefixesActuallyUsed, element.getNamespacePrefix());
354 
355     String jcrPrimaryType = element.getAttributeValue("primaryType", JCR_NAMESPACE);
356     boolean isRepositoryUserGroup = Strings.CS.equals(jcrPrimaryType, "rep:User") || Strings.CS.equals(jcrPrimaryType, "rep:Group");
357     boolean isReplicationElement = Strings.CS.equals(jcrPrimaryType, "cq:Page")
358         || Strings.CS.equals(jcrPrimaryType, "dam:Asset")
359         || Strings.CS.equals(jcrPrimaryType, "cq:Template");
360     boolean isContent = insideReplicationElement && Strings.CS.equals(element.getQualifiedName(), "jcr:content");
361     boolean setReplicationAttributes = isContent && markReplicationActivated;
362 
363     List<Attribute> attributes = new ArrayList<>(element.getAttributes());
364     for (Attribute attribute : attributes) {
365       boolean excluded = false;
366       if (matches(attribute.getQualifiedName(), this.excludeProperties, false)) {
367         if (isRepositoryUserGroup && Strings.CS.equals(attribute.getQualifiedName(), JcrConstants.JCR_UUID)) {
368           // keep jcr:uuid property for groups and users, otherwise they cannot be imported again
369         }
370         else {
371           attribute.detach();
372           excluded = true;
373         }
374       }
375       else if (Strings.CS.equals(attribute.getQualifiedName(), PRIMARYTYPE_PROPERTY)) {
376         String namespacePrefix = StringUtils.substringBefore(attribute.getValue(), ":");
377         collectNamespacePrefix(namespacePrefixesActuallyUsed, namespacePrefix);
378       }
379       else if (Strings.CS.equals(attribute.getQualifiedName(), MIXINS_PROPERTY)) {
380         String filteredValue = filterMixinsPropertyValue(attribute.getValue(), namespacePrefixesActuallyUsed);
381         if (StringUtils.isBlank(filteredValue)) {
382           attribute.detach();
383         }
384         else {
385           attribute.setValue(filteredValue);
386         }
387       }
388       else if (Strings.CS.startsWith(attribute.getValue(), "{Name}")) {
389         collectNamespacePrefixNameArray(namespacePrefixesActuallyUsed, attribute.getValue());
390         // alphabetically sort name values
391         attribute.setValue(sortReferenceValues(attribute.getValue(), PropertyType.NAME));
392       }
393       else if (Strings.CS.startsWith(attribute.getValue(), "{WeakReference}")) {
394         // alphabetically sort weak reference values
395         attribute.setValue(sortReferenceValues(attribute.getValue(), PropertyType.WEAKREFERENCE));
396       }
397       if (!excluded) {
398         collectNamespacePrefix(namespacePrefixesActuallyUsed, attribute.getNamespacePrefix());
399       }
400     }
401 
402     // set replication status for jcr:content nodes inside cq:Page nodes
403     if (setReplicationAttributes && matches(path, markReplicationActivatedIncludeNodes, true)) {
404       addMixin(element, "cq:ReplicationStatus");
405       element.setAttribute("lastReplicated", "{Date}" + dateLastReplicated, CQ_NAMESPACE);
406       element.setAttribute("lastReplicationAction", "Activate", CQ_NAMESPACE);
407       collectNamespacePrefix(namespacePrefixesActuallyUsed, CQ_NAMESPACE.getPrefix());
408     }
409 
410     // if current element is a replication element, but the jcr:content node to set the replication attributes to is missing, add it
411     if (isReplicationElement && element.getChild("content", JCR_NAMESPACE) == null
412         && matches(path + "/jcr:content", markReplicationActivatedIncludeNodes, true)) {
413       Element contentNode = new Element("content", JCR_NAMESPACE);
414       String jcrContentPrimaryType = Strings.CS.equals(jcrPrimaryType, "cq:Template") ? "cq:PageContent" : jcrPrimaryType + "Content";
415       contentNode.setAttribute("primaryType", jcrContentPrimaryType, JCR_NAMESPACE);
416       element.addContent(contentNode);
417     }
418 
419     List<Element> children = new ArrayList<>(element.getChildren());
420     for (Element child : children) {
421       applyXmlExcludes(child, path, namespacePrefixesActuallyUsed, (insideReplicationElement || isReplicationElement) && !isContent);
422     }
423   }
424 
425   private String filterMixinsPropertyValue(String value, Set<String> namespacePrefixesActuallyUsed) {
426     if (this.excludeMixins.length == 0 || StringUtils.isBlank(value)) {
427       return value;
428     }
429 
430     List<String> mixins = new ArrayList<>();
431     for (String mixin : DocViewUtil.parseValues(value)) {
432       if (!matches(mixin, this.excludeMixins, false)) {
433         String namespacePrefix = StringUtils.substringBefore(mixin, ":");
434         collectNamespacePrefix(namespacePrefixesActuallyUsed, namespacePrefix);
435         mixins.add(mixin);
436       }
437     }
438 
439     if (mixins.isEmpty()) {
440       return null;
441     }
442 
443     return DocViewUtil.formatValues(mixins);
444   }
445 
446   private void addMixin(Element element, String mixin) {
447     String mixinsString = element.getAttributeValue("mixinTypes", JCR_NAMESPACE);
448 
449     List<String> mixins = new ArrayList<>();
450     if (!StringUtils.isBlank(mixinsString)) {
451       for (String item : DocViewUtil.parseValues(mixinsString)) {
452         mixins.add(item);
453       }
454     }
455     if (!mixins.contains(mixin)) {
456       mixins.add(mixin);
457     }
458 
459     element.setAttribute("mixinTypes", DocViewUtil.formatValues(mixins), JCR_NAMESPACE);
460   }
461 
462   private void collectNamespacePrefix(Set<String> prefixes, String prefix) {
463     if (StringUtils.isNotBlank(prefix)) {
464       prefixes.add(prefix);
465     }
466   }
467 
468   private void collectNamespacePrefixNameArray(Set<String> prefixes, String value) {
469     for (String item : DocViewUtil.parseValues(value)) {
470       String namespacePrefix = StringUtils.substringBefore(item, ":");
471       collectNamespacePrefix(prefixes, namespacePrefix);
472     }
473   }
474 
475   /**
476    * Sort weak reference values alphabetically to ensure consistent ordering.
477    * @param value Property value
478    * @param propertyType Property type from {@link PropertyType}
479    * @return Property value with sorted references
480    */
481   private String sortReferenceValues(String value, int propertyType) {
482     Set<String> refs = new TreeSet<>();
483     for (String item : DocViewUtil.parseValues(value)) {
484       refs.add(item);
485     }
486     return DocViewUtil.formatValues(new ArrayList<>(refs), propertyType);
487   }
488 
489 }