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());
+        }
+    }
+}