You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@jackrabbit.apache.org by da...@apache.org on 2026/10/16 07:42:43 UTC
(jackrabbit-filevault) 01/01: JCRVLT-859 : escape sniffable files during export
This is an automated email from the ASF dual-hosted git repository.
rishabhdaim pushed a commit to branch JCRVLT-859
in repository https://gitbox.apache.org/repos/asf/jackrabbit-filevault.git
commit 4465526f25820777785a61189293cce5675fdca9
Author: Rishabh Kumar <di...@adobe.com>
AuthorDate: Fri Oct 16 13:12:33 2026 +0530
JCRVLT-859 : escape sniffable files during export
Backport file export escaping to 3.2.x using the existing XmlAnalyzer and in-module integration tests.
Co-authored-by: Copilot <22...@users.noreply.github.com>
---
.../vault/fs/impl/aggregator/FileAggregator.java | 72 +++++++--
.../integration/SniffedFileEscapeTest.java | 171 +++++++++++++++++++++
2 files changed, 234 insertions(+), 9 deletions(-)
diff --git a/vault-core/src/main/java/org/apache/jackrabbit/vault/fs/impl/aggregator/FileAggregator.java b/vault-core/src/main/java/org/apache/jackrabbit/vault/fs/impl/aggregator/FileAggregator.java
index 4d2f7979..5e453d6c 100644
--- a/vault-core/src/main/java/org/apache/jackrabbit/vault/fs/impl/aggregator/FileAggregator.java
+++ b/vault-core/src/main/java/org/apache/jackrabbit/vault/fs/impl/aggregator/FileAggregator.java
@@ -17,6 +17,9 @@
package org.apache.jackrabbit.vault.fs.impl.aggregator;
+import java.io.IOException;
+import java.io.InputStream;
+
import javax.jcr.Node;
import javax.jcr.Property;
import javax.jcr.RepositoryException;
@@ -33,15 +36,20 @@ import org.apache.jackrabbit.vault.fs.api.DumpContext;
import org.apache.jackrabbit.vault.fs.api.Dumpable;
import org.apache.jackrabbit.vault.fs.api.ImportInfo;
import org.apache.jackrabbit.vault.fs.api.ItemFilterSet;
+import org.apache.jackrabbit.vault.fs.api.SerializationType;
import org.apache.jackrabbit.vault.fs.impl.ArtifactSetImpl;
import org.apache.jackrabbit.vault.fs.impl.io.DocViewSerializer;
import org.apache.jackrabbit.vault.fs.impl.io.ImportInfoImpl;
+import org.apache.jackrabbit.vault.fs.impl.io.XmlAnalyzer;
import org.apache.jackrabbit.vault.fs.io.Serializer;
import org.apache.jackrabbit.vault.util.Constants;
import org.apache.jackrabbit.vault.util.JcrConstants;
import org.apache.jackrabbit.vault.util.MimeTypes;
import org.apache.jackrabbit.vault.util.PathUtil;
import org.apache.jackrabbit.vault.util.Text;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xml.sax.InputSource;
/**
* A file aggregate contains nt:file or nt:resource nodes.
@@ -54,6 +62,8 @@ import org.apache.jackrabbit.vault.util.Text;
*/
public class FileAggregator implements Aggregator, Dumpable {
+ private static final Logger log = LoggerFactory.getLogger(FileAggregator.class);
+
/**
* filter that regulates what to be included in the aggregate
*/
@@ -223,24 +233,68 @@ public class FileAggregator implements Aggregator, Dumpable {
needsDir = !"utf-8".equals(encoding) || MimeTypes.isBinary(mimeType);
}
- // create file artifact
String name = aggregate.getName();
- artifacts.add(null, name, "", ArtifactType.FILE,
- content.getProperty(JcrConstants.JCR_DATA), lastModified);
-
- // create .dir artifact
- if (needsDir) {
- // in this case, we create a directory artifact
+ Property data = content.getProperty(JcrConstants.JCR_DATA);
+ if (needsContentEscape(name, data)) {
+ // Keep colliding file content opaque so the importer cannot treat it as docview.
Artifact parent = new DirectoryArtifact(name, ".dir");
artifacts.add(parent);
- // and extra
Serializer ser = new DocViewSerializer(aggregate);
- // hack: do better
artifacts.add(parent, "", Constants.DOT_CONTENT_XML, ArtifactType.PRIMARY, ser, 0);
+ artifacts.add(
+ parent,
+ JcrConstants.JCR_CONTENT + "/" + JcrConstants.JCR_DATA,
+ ".binary",
+ ArtifactType.BINARY,
+ data,
+ lastModified);
+ } else {
+ // create file artifact
+ artifacts.add(null, name, "", ArtifactType.FILE, data, lastModified);
+
+ // create .dir artifact
+ if (needsDir) {
+ // in this case, we create a directory artifact
+ Artifact parent = new DirectoryArtifact(name, ".dir");
+ artifacts.add(parent);
+ // and extra
+ Serializer ser = new DocViewSerializer(aggregate);
+ // hack: do better
+ artifacts.add(parent, "", Constants.DOT_CONTENT_XML, ArtifactType.PRIMARY, ser, 0);
+ }
}
return artifacts;
}
+ /**
+ * Determines whether the raw bytes of a stored file would be indistinguishable from
+ * a docview serialization on import, and must therefore be escaped into the
+ * {@code .dir}/{@code .content.xml} + binary-sidecar shape instead of being written
+ * out verbatim under {@code name}.
+ *
+ * @param name the repository name of the file (e.g. {@code "evil.xml"})
+ * @param data the file's {@code jcr:data} property
+ * @return {@code true} if the file's name or content collides with the docview
+ * serialization conventions
+ * @throws RepositoryException if the property cannot be read
+ */
+ private static boolean needsContentEscape(String name, Property data) throws RepositoryException {
+ if (Constants.DOT_CONTENT_XML.equals(name)) {
+ // always collides: this entry is consumed as the containing folder's own
+ // docview on import (see Importer#prepare), regardless of its content.
+ return true;
+ }
+ if (!name.endsWith(".xml")) {
+ return false;
+ }
+ try (InputStream in = data.getBinary().getStream()) {
+ return XmlAnalyzer.analyze(new InputSource(in)) == SerializationType.XML_DOCVIEW;
+ } catch (IOException e) {
+ log.warn("Unable to analyze file {}; escaping its content to prevent docview promotion", name, e);
+ return true;
+ }
+ }
+
/**
* {@inheritDoc}
*/
diff --git a/vault-core/src/test/java/org/apache/jackrabbit/vault/packaging/integration/SniffedFileEscapeTest.java b/vault-core/src/test/java/org/apache/jackrabbit/vault/packaging/integration/SniffedFileEscapeTest.java
new file mode 100644
index 00000000..9f1731ff
--- /dev/null
+++ b/vault-core/src/test/java/org/apache/jackrabbit/vault/packaging/integration/SniffedFileEscapeTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.jackrabbit.vault.packaging.integration;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Properties;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import javax.jcr.Node;
+import javax.jcr.RepositoryException;
+import javax.jcr.nodetype.NodeType;
+
+import org.apache.jackrabbit.commons.JcrUtils;
+import org.apache.jackrabbit.vault.fs.api.PathFilterSet;
+import org.apache.jackrabbit.vault.fs.config.DefaultMetaInf;
+import org.apache.jackrabbit.vault.fs.config.DefaultWorkspaceFilter;
+import org.apache.jackrabbit.vault.fs.impl.aggregator.FileAggregator;
+import org.apache.jackrabbit.vault.packaging.ExportOptions;
+import org.apache.jackrabbit.vault.packaging.PackageException;
+import org.apache.jackrabbit.vault.packaging.VaultPackage;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Verifies that stored files whose name/content would collide with the docview
+ * serialization conventions ({@code *.xml} files that sniff as Document View XML,
+ * and files literally named {@code .content.xml}) are exported in a form that
+ * round-trips losslessly as an opaque file, instead of being re-imported as node
+ * structure. Tests the escaping performed by {@link FileAggregator}.
+ */
+public class SniffedFileEscapeTest extends IntegrationTestBase {
+
+ private static final String EVIL_DOCVIEW = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ + "<jcr:root xmlns:jcr=\"http://www.jcp.org/jcr/1.0\" xmlns:nt=\"http://www.jcp.org/jcr/nt/1.0\"\n"
+ + " jcr:primaryType=\"nt:unstructured\"\n"
+ + " jcr:uuid=\"12345678-1234-1234-1234-123456789012\"\n"
+ + " evilMarker=\"pwned\"/>\n";
+
+ /**
+ * An inert {@code *.xml} file whose bytes happen to sniff as Document View XML must
+ * not be promoted to node structure on re-import: it must round-trip as the very
+ * same file, byte for byte, and must not create any additional node.
+ */
+ @Test
+ public void sniffableXmlFileRoundTripsAsFile() throws IOException, RepositoryException, PackageException {
+ byte[] data = EVIL_DOCVIEW.getBytes(StandardCharsets.UTF_8);
+ Node tmp = admin.getRootNode().addNode("tmp", NodeType.NT_FOLDER);
+ JcrUtils.putFile(tmp, "evil.xml", "text/xml", new ByteArrayInputStream(data));
+ admin.save();
+
+ Set<String> entries = assembleAndReinstallPackage("/tmp");
+
+ // the raw bytes must survive unchanged...
+ assertNodeExists("/tmp/evil.xml");
+ assertProperty("/tmp/evil.xml/jcr:content/jcr:data", new String(data, StandardCharsets.UTF_8));
+ // ...and must never be sniffed into a sibling node named after the stripped extension
+ assertNodeMissing("/tmp/evil");
+ assertPropertyMissing("/tmp/evil.xml/evilMarker");
+
+ // the archive must not contain a raw "evil.xml" entry (which an old importer would sniff);
+ // it must be escaped into the ".dir" aggregate shape instead.
+ Assert.assertFalse(
+ "archive must not contain a raw, sniffable evil.xml entry", entries.contains("jcr_root/tmp/evil.xml"));
+ Assert.assertTrue(
+ "archive must contain the escaped .content.xml docview",
+ entries.contains("jcr_root/tmp/evil.xml.dir/.content.xml"));
+ }
+
+ /**
+ * An {@code nt:file} literally named {@code .content.xml} must not be confused with
+ * the containing folder's own serialization, and must round-trip as a file.
+ */
+ @Test
+ public void dotContentXmlNamedFileRoundTripsAsFile() throws IOException, RepositoryException, PackageException {
+ byte[] data = "just some file content, not a docview".getBytes(StandardCharsets.UTF_8);
+ Node tmp = admin.getRootNode().addNode("tmp", NodeType.NT_FOLDER);
+ Node folder = tmp.addNode("folder", NodeType.NT_FOLDER);
+ JcrUtils.putFile(folder, ".content.xml", "text/plain", new ByteArrayInputStream(data));
+ admin.save();
+
+ assembleAndReinstallPackage("/tmp");
+
+ assertNodeExists("/tmp/folder");
+ assertNodeExists("/tmp/folder/.content.xml");
+ assertProperty("/tmp/folder/.content.xml/jcr:content/jcr:data", new String(data, StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Control case: a plain, non-docview {@code *.xml} file keeps the existing raw
+ * export layout (no behavioral change for the overwhelming majority of files).
+ */
+ @Test
+ public void plainXmlFileKeepsRawLayout() throws IOException, RepositoryException, PackageException {
+ byte[] data = "<config><item/></config>".getBytes(StandardCharsets.UTF_8);
+ Node tmp = admin.getRootNode().addNode("tmp", NodeType.NT_FOLDER);
+ JcrUtils.putFile(tmp, "plain.xml", "text/xml", new ByteArrayInputStream(data));
+ admin.save();
+
+ Set<String> entries = assembleAndReinstallPackage("/tmp");
+
+ assertNodeExists("/tmp/plain.xml");
+ assertProperty("/tmp/plain.xml/jcr:content/jcr:data", new String(data, StandardCharsets.UTF_8));
+ Assert.assertTrue(
+ "plain, non-docview xml files must keep the raw archive entry",
+ entries.contains("jcr_root/tmp/plain.xml"));
+ Assert.assertFalse(
+ "plain, non-docview xml files must not be escaped into .dir",
+ entries.contains("jcr_root/tmp/plain.xml.dir/.content.xml"));
+ }
+
+ private Set<String> assembleAndReinstallPackage(String root)
+ throws IOException, PackageException, RepositoryException {
+ File pkgFile = tempFolder.newFile();
+
+ try {
+ ExportOptions options = new ExportOptions();
+ DefaultMetaInf meta = new DefaultMetaInf();
+ DefaultWorkspaceFilter filter = new DefaultWorkspaceFilter();
+ filter.add(new PathFilterSet(root));
+ meta.setFilter(filter);
+
+ Properties props = new Properties();
+ props.setProperty(VaultPackage.NAME_GROUP, "jackrabbit/test");
+ props.setProperty(VaultPackage.NAME_NAME, "sniffed-file-escape-package");
+ meta.setProperties(props);
+ options.setMetaInf(meta);
+
+ packMgr.assemble(admin, options, pkgFile).close();
+
+ Set<String> entries = new HashSet<>();
+ try (ZipFile zip = new ZipFile(pkgFile)) {
+ Enumeration<? extends ZipEntry> en = zip.entries();
+ while (en.hasMoreElements()) {
+ entries.add(en.nextElement().getName());
+ }
+ }
+
+ clean(root);
+ try (VaultPackage vp = packMgr.open(pkgFile)) {
+ vp.extract(admin, getDefaultOptions());
+ }
+ return entries;
+ } finally {
+ Files.delete(pkgFile.toPath());
+ }
+ }
+}