`.
+The starting node must be an existing absolute znode path. The maximum depth is
+a non-negative integer: 0 prints every non-leaf node, 1 prints the starting
+node and its non-leaf children, 2 adds another level, and so on.
+**Depth limits output only, not traversal or totals.**
+
+`children` is the number of all descendants, not just immediate children.
+`data` is the sum of payload bytes for the node itself and all descendants.
+Null data contributes zero bytes. Leaves contribute to their ancestors' totals
+but are not printed; selecting a leaf produces no summary entries.
+
+For example, if `/app` has 2 payload bytes, `/app/branch` has 3, and
+`/app/branch/leaf` has 5, the summary is:
+
+```text
+ /app
+ children: 2
+ data: 10
+-- /app/branch
+-- children: 1
+-- data: 8
+```
+
+#### Snapshot analysis limitations
+
+Both tools run offline, read files without modifying them, and support
+uncompressed, `.gz`, and `.snappy` snapshots (including mixed formats in the
+comparer). They validate snapshot checksums and report unreadable or corrupt
+input rather than silently producing a successful analysis. Invalid arguments,
+file paths or summary starting paths exit with code 2; snapshot read failures
+exit with code 1. Windows launchers with the same names and a `.cmd` extension
+are also included. The launchers use the existing `zkEnv` configuration.
+
+Both tools **include ephemeral znodes** present in the snapshot; they do not
+report session records. This reflects the upstream traversal behavior, despite
+the original comparer's description claiming that ephemerals were ignored.
+Neither tool compares payload contents, ACLs, versions or other znode metadata.
+**Equal sizes/counts do not prove identical contents.** Snapshots may be fuzzy:
+these tools do not replay transaction logs, reconstruct point-in-time state,
+or establish transaction-consistent equality. The tools load snapshots into
+memory, and recursive traversal still visits the full selected subtree.
+
## Testing
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotComparer.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotComparer.java
new file mode 100644
index 00000000000..39c9616ce27
--- /dev/null
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotComparer.java
@@ -0,0 +1,435 @@
+/*
+ * 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.zookeeper.server;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PushbackInputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.zip.CheckedInputStream;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.apache.jute.BinaryInputArchive;
+import org.apache.jute.InputArchive;
+import org.apache.zookeeper.ZKUtil;
+import org.apache.zookeeper.server.persistence.FileSnap;
+import org.apache.zookeeper.server.persistence.SnapStream;
+import org.apache.zookeeper.util.ServiceUtils;
+
+/**
+ * Compares snapshot subtree data sizes and descendant counts, including ephemeral nodes.
+ * Sessions, payload contents, ACLs and other metadata are not compared. Equal sizes and
+ * counts do not establish identical contents or transaction-consistent state.
+ *
+ * Backported from Apache ZooKeeper commit f90060b83da4bfcca58ada93a57fedb40a069387
+ * (ZOOKEEPER-3427).
+ */
+public class SnapshotComparer {
+
+ private static final String LEFT_OPTION = "left";
+ private static final String RIGHT_OPTION = "right";
+ private static final String BYTE_THRESHOLD_OPTION = "bytes";
+ private static final String NODE_THRESHOLD_OPTION = "nodes";
+ private static final String DEBUG_OPTION = "debug";
+ private static final String INTERACTIVE_OPTION = "interactive";
+
+ private final Options options = new Options();
+
+ private SnapshotComparer() {
+ options.addOption(Option.builder("l").longOpt(LEFT_OPTION).hasArg().required()
+ .argName("LEFT").desc("(Required) The left snapshot file.").build());
+ options.addOption(Option.builder("r").longOpt(RIGHT_OPTION).hasArg().required()
+ .argName("RIGHT").desc("(Required) The right snapshot file.").build());
+ options.addOption(Option.builder("b").longOpt(BYTE_THRESHOLD_OPTION).hasArg().required()
+ .argName("BYTETHRESHOLD")
+ .desc("(Required) The node data delta size threshold, in bytes, for printing the node.").build());
+ options.addOption(Option.builder("n").longOpt(NODE_THRESHOLD_OPTION).hasArg().required()
+ .argName("NODETHRESHOLD")
+ .desc("(Required) The descendant node delta size threshold, in nodes, for printing the node.").build());
+ options.addOption("d", DEBUG_OPTION, false, "Use debug output.");
+ options.addOption("i", INTERACTIVE_OPTION, false, "Enter interactive mode.");
+ }
+
+ private void usage() {
+ new HelpFormatter().printHelp(120, "java -cp " + SnapshotComparer.class.getName(), "", options, "");
+ }
+
+ public static void main(String[] args) {
+ new SnapshotComparer().compareSnapshots(args);
+ }
+
+ private void compareSnapshots(String[] args) {
+ CommandLine parsedOptions;
+ File left;
+ File right;
+ int byteThreshold;
+ int nodeThreshold;
+ try {
+ parsedOptions = new DefaultParser().parse(options, args);
+ if (!parsedOptions.getArgList().isEmpty()) {
+ throw new IllegalArgumentException("Unexpected arguments: " + parsedOptions.getArgList());
+ }
+ byteThreshold = Integer.parseInt(parsedOptions.getOptionValue(BYTE_THRESHOLD_OPTION));
+ nodeThreshold = Integer.parseInt(parsedOptions.getOptionValue(NODE_THRESHOLD_OPTION));
+ if (byteThreshold < 0 || nodeThreshold < 0) {
+ throw new IllegalArgumentException("Thresholds must be non-negative integers.");
+ }
+ left = new File(parsedOptions.getOptionValue(LEFT_OPTION));
+ right = new File(parsedOptions.getOptionValue(RIGHT_OPTION));
+ for (File file : new File[]{left, right}) {
+ String error = ZKUtil.validateFileInput(file.toString());
+ if (error != null) {
+ throw new IllegalArgumentException(error);
+ }
+ }
+ } catch (ParseException | IllegalArgumentException e) {
+ System.err.println(e.getMessage());
+ usage();
+ ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue());
+ return;
+ }
+
+ boolean debug = parsedOptions.hasOption(DEBUG_OPTION);
+ boolean interactive = parsedOptions.hasOption(INTERACTIVE_OPTION);
+ System.out.println("Successfully parsed options!");
+ try {
+ TreeInfo leftTree = new TreeInfo(left);
+ TreeInfo rightTree = new TreeInfo(right);
+ System.out.println(leftTree);
+ System.out.println(rightTree);
+ compareTrees(leftTree, rightTree, byteThreshold, nodeThreshold, debug, interactive);
+ } catch (IOException e) {
+ System.err.println("Unable to read snapshot: " + e.getMessage());
+ ServiceUtils.requestSystemExit(ExitCode.UNEXPECTED_ERROR.getValue());
+ }
+ }
+
+ private static class TreeInfo {
+
+ private static class TreeNode {
+
+ final String label;
+ final long size;
+ final List children = new ArrayList<>();
+ long descendantSize;
+ long descendantCount;
+
+ TreeNode(String label, long size) {
+ this.label = label;
+ this.size = size;
+ }
+
+ void populateChildren(DataTree dataTree, TreeInfo treeInfo, int depth) {
+ DataNode node = dataTree.getNode(label);
+ Set childLabels;
+ synchronized (node) {
+ childLabels = node.getChildren();
+ }
+ for (String childName : childLabels) {
+ String childPath = label + "/" + childName;
+ DataNode childNode = dataTree.getNode(childPath);
+ long childSize;
+ synchronized (childNode) {
+ childSize = childNode.data == null ? 0 : childNode.data.length;
+ }
+ TreeNode child = new TreeNode(childPath, childSize);
+ child.populateChildren(dataTree, treeInfo, depth + 1);
+ children.add(child);
+ }
+ descendantSize = size;
+ descendantCount = children.size();
+ for (TreeNode child : children) {
+ descendantSize += child.descendantSize;
+ descendantCount += child.descendantCount;
+ }
+ treeInfo.registerNode(this, depth);
+ }
+
+ }
+
+ final TreeNode root;
+ long count;
+ final List> nodesAtDepths = new ArrayList<>();
+ final Map nodesByName = new HashMap<>();
+
+ TreeInfo(File snapshot) throws IOException {
+ long beginning = System.nanoTime();
+ DataTree dataTree = getSnapshot(snapshot);
+ System.out.printf("Deserialized snapshot in %s in %f seconds%n",
+ snapshot.getName(), (System.nanoTime() - beginning) / 1_000_000_000.0);
+ beginning = System.nanoTime();
+ DataNode rootNode = dataTree.getNode("");
+ long size;
+ synchronized (rootNode) {
+ size = rootNode.data == null ? 0 : rootNode.data.length;
+ }
+ root = new TreeNode("", size);
+ root.populateChildren(dataTree, this, 0);
+ System.out.printf("Processed data tree in %f seconds%n", (System.nanoTime() - beginning) / 1_000_000_000.0);
+ }
+
+ void registerNode(TreeNode node, int depth) {
+ while (depth >= nodesAtDepths.size()) {
+ nodesAtDepths.add(new ArrayList<>());
+ }
+ nodesAtDepths.get(depth).add(node);
+ nodesByName.put(node.label, node);
+ count++;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append(String.format("Node count: %d%n", count));
+ builder.append(String.format("Total size: %d%n", root.descendantSize));
+ builder.append(String.format("Max depth: %d%n", nodesAtDepths.size()));
+ for (int i = 0; i < nodesAtDepths.size(); i++) {
+ builder.append(String.format("Count of nodes at depth %d: %d%n", i, nodesAtDepths.get(i).size()));
+ }
+ return builder.toString();
+ }
+
+ }
+
+ static DataTree getSnapshot(File file) throws IOException {
+ try (CheckedInputStream stream = SnapStream.getInputStream(file);
+ PushbackInputStream input = new PushbackInputStream(stream)) {
+ InputArchive archive = BinaryInputArchive.getArchive(input);
+ DataTree dataTree = new DataTree();
+ new FileSnap(null).deserialize(dataTree, new HashMap(), archive);
+ checkSealIntegrity(stream, archive);
+
+ // Distinguish an absent digest in older snapshots from a truncated digest.
+ int next = input.read();
+ if (next != -1) {
+ input.unread(next);
+ dataTree.new ZxidDigest().deserialize(archive);
+ checkSealIntegrity(stream, archive);
+ if (input.read() != -1) {
+ throw new IOException("Unexpected data after snapshot");
+ }
+ }
+ return dataTree;
+ } catch (IOException | NumberFormatException e) {
+ throw new IOException(file + ": " + e, e);
+ }
+ }
+
+ private static void checkSealIntegrity(CheckedInputStream stream, InputArchive archive) throws IOException {
+ // SnapStream's seal checker is package-private on this branch.
+ long checksum = stream.getChecksum().getValue();
+ long expected = archive.readLong("val");
+ String path = archive.readString("path");
+ if (checksum != expected || !"/".equals(path)) {
+ throw new IOException("CRC corruption or invalid snapshot seal");
+ }
+ }
+
+ private static void printThresholdInfo(int byteThreshold, int nodeThreshold) {
+ System.out.printf("Printing analysis for nodes difference larger than %d bytes or node count difference larger than %d.%n",
+ byteThreshold, nodeThreshold);
+ }
+
+ private static void compareTrees(
+ TreeInfo left,
+ TreeInfo right,
+ int byteThreshold,
+ int nodeThreshold,
+ boolean debug,
+ boolean interactive) {
+ int maxDepth = Math.max(left.nodesAtDepths.size(), right.nodesAtDepths.size());
+ if (!interactive) {
+ printThresholdInfo(byteThreshold, nodeThreshold);
+ for (int i = 0; i < maxDepth; i++) {
+ System.out.printf("Analysis for depth %d%n", i);
+ compareLine(left, right, i, byteThreshold, nodeThreshold, debug, false);
+ }
+ } else {
+ try (Scanner scanner = new Scanner(System.in)) {
+ int currentDepth = 0;
+ while (currentDepth < maxDepth) {
+ System.out.printf("Current depth is %d%n", currentDepth);
+ System.out.println("- Press enter to move to print current depth layer;\n"
+ + "- Type a number to jump to and print all nodes at a given depth;\n"
+ + "- Enter an ABSOLUTE path to print the immediate subtree of a node. Path must start with '/'.");
+ if (!scanner.hasNextLine()) {
+ System.out.println("End of input.");
+ return;
+ }
+ String input = scanner.nextLine();
+ printThresholdInfo(byteThreshold, nodeThreshold);
+ if (input.isEmpty()) {
+ System.out.printf("Analysis for depth %d%n", currentDepth);
+ compareLine(left, right, currentDepth, byteThreshold, nodeThreshold, debug, true);
+ currentDepth++;
+ } else if (input.startsWith("/")) {
+ System.out.printf("Analysis for node %s%n", input);
+ compareSubtree(left, right, input, byteThreshold, nodeThreshold, debug);
+ } else {
+ try {
+ int depth = Integer.parseInt(input);
+ if (depth < 0 || depth >= maxDepth) {
+ System.out.printf("Depth must be in range [%d, %d]%n", 0, maxDepth - 1);
+ continue;
+ }
+ currentDepth = depth;
+ System.out.printf("Analysis for depth %d%n", currentDepth);
+ compareLine(left, right, currentDepth, byteThreshold, nodeThreshold, debug, true);
+ } catch (NumberFormatException e) {
+ System.out.printf("Input %s is not valid. Depth must be in range [%d, %d]. "
+ + "Path must be an absolute path which starts with '/'.%n",
+ input, 0, maxDepth - 1);
+ }
+ }
+ System.out.println();
+ }
+ }
+ }
+ System.out.println("All layers compared.");
+ }
+
+ private static void compareSubtree(
+ TreeInfo left,
+ TreeInfo right,
+ String path,
+ int byteThreshold,
+ int nodeThreshold,
+ boolean debug) {
+ String label = "/".equals(path) ? "" : path;
+ TreeInfo.TreeNode leftRoot = left.nodesByName.get(label);
+ TreeInfo.TreeNode rightRoot = right.nodesByName.get(label);
+ if (leftRoot == null && rightRoot == null) {
+ System.out.printf("Path %s is neither found in left tree nor right tree.%n", path);
+ } else {
+ List leftList = leftRoot == null ? Collections.emptyList() : leftRoot.children;
+ List rightList = rightRoot == null ? Collections.emptyList() : rightRoot.children;
+ compareNodes(leftList, rightList, byteThreshold, nodeThreshold, debug, true);
+ }
+ }
+
+ private static void compareLine(
+ TreeInfo left,
+ TreeInfo right,
+ int depth,
+ int byteThreshold,
+ int nodeThreshold,
+ boolean debug,
+ boolean interactive) {
+ List leftList = depth >= left.nodesAtDepths.size()
+ ? Collections.emptyList() : left.nodesAtDepths.get(depth);
+ List rightList = depth >= right.nodesAtDepths.size()
+ ? Collections.emptyList() : right.nodesAtDepths.get(depth);
+ compareNodes(leftList, rightList, byteThreshold, nodeThreshold, debug, interactive);
+ }
+
+ private static void compareNodes(
+ List leftList,
+ List rightList,
+ int byteThreshold,
+ int nodeThreshold,
+ boolean debug,
+ boolean interactive) {
+ Comparator comparator = Comparator.comparing(node -> node.label);
+ Collections.sort(leftList, comparator);
+ Collections.sort(rightList, comparator);
+ int leftIndex = 0;
+ int rightIndex = 0;
+ while (leftIndex < leftList.size() || rightIndex < rightList.size()) {
+ TreeInfo.TreeNode leftNode = leftIndex < leftList.size() ? leftList.get(leftIndex) : null;
+ TreeInfo.TreeNode rightNode = rightIndex < rightList.size() ? rightList.get(rightIndex) : null;
+ if (leftNode != null && rightNode != null) {
+ if (debug) {
+ System.out.printf("Comparing %s to %s%n", leftNode.label, rightNode.label);
+ }
+ int result = comparator.compare(leftNode, rightNode);
+ if (result < 0) {
+ if (debug) {
+ System.out.println("left is less");
+ }
+ printOnly(leftNode, "left", byteThreshold, nodeThreshold, debug, interactive);
+ leftIndex++;
+ } else if (result > 0) {
+ if (debug) {
+ System.out.println("right is less");
+ }
+ printOnly(rightNode, "right", byteThreshold, nodeThreshold, debug, interactive);
+ rightIndex++;
+ } else {
+ if (debug) {
+ System.out.println("same");
+ }
+ printBoth(leftNode, rightNode, byteThreshold, nodeThreshold, debug, interactive);
+ leftIndex++;
+ rightIndex++;
+ }
+ } else if (leftNode != null) {
+ printOnly(leftNode, "left", byteThreshold, nodeThreshold, debug, interactive);
+ leftIndex++;
+ } else {
+ printOnly(rightNode, "right", byteThreshold, nodeThreshold, debug, interactive);
+ rightIndex++;
+ }
+ }
+ }
+
+ private static void printOnly(
+ TreeInfo.TreeNode node,
+ String side,
+ int byteThreshold,
+ int nodeThreshold,
+ boolean debug,
+ boolean interactive) {
+ if (node.descendantSize > byteThreshold || node.descendantCount > nodeThreshold) {
+ System.out.printf("Node %s found only in %s tree. Descendant size: %d. Descendant count: %d%n",
+ node.label, side, node.descendantSize, node.descendantCount);
+ } else if (debug || interactive) {
+ System.out.printf("Filtered %s node %s of size %d%n", side, node.label, node.descendantSize);
+ }
+ }
+
+ private static void printBoth(
+ TreeInfo.TreeNode left,
+ TreeInfo.TreeNode right,
+ int byteThreshold,
+ int nodeThreshold,
+ boolean debug,
+ boolean interactive) {
+ long byteDelta = right.descendantSize - left.descendantSize;
+ long nodeDelta = right.descendantCount - left.descendantCount;
+ if (Math.abs(byteDelta) > byteThreshold || Math.abs(nodeDelta) > nodeThreshold) {
+ System.out.printf("Node %s found in both trees. Delta: %d bytes, %d descendants%n",
+ left.label, byteDelta, nodeDelta);
+ } else if (debug || interactive) {
+ System.out.printf("Filtered node %s of left size %d, right size %d%n",
+ left.label, left.descendantSize, right.descendantSize);
+ }
+ }
+
+}
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java
new file mode 100644
index 00000000000..d34bc38c4b6
--- /dev/null
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/SnapshotRecursiveSummary.java
@@ -0,0 +1,121 @@
+/*
+ * 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.zookeeper.server;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+import org.apache.yetus.audience.InterfaceAudience;
+import org.apache.zookeeper.ZKUtil;
+import org.apache.zookeeper.common.PathUtils;
+import org.apache.zookeeper.util.ServiceUtils;
+
+/**
+ * Recursively summarizes snapshot subtree data sizes and descendant counts.
+ * Only non-leaf nodes are printed, but totals include all descendants and ephemeral nodes.
+ * The maximum depth limits output, not traversal; zero means unlimited output depth.
+ *
+ * Backported from Apache ZooKeeper commit 05b215994f5e145c2758c4089828b57ba471b329
+ * (ZOOKEEPER-4566).
+ */
+@InterfaceAudience.Public
+public class SnapshotRecursiveSummary {
+
+ public static void main(String[] args) {
+ if (args.length != 3) {
+ System.err.println(getUsage());
+ ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue());
+ return;
+ }
+ try {
+ new SnapshotRecursiveSummary().run(args[0], args[1], Integer.parseInt(args[2]));
+ } catch (IllegalArgumentException e) {
+ System.err.println(e.getMessage());
+ System.err.println(getUsage());
+ ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue());
+ } catch (IOException e) {
+ System.err.println("Unable to read snapshot: " + e.getMessage());
+ ServiceUtils.requestSystemExit(ExitCode.UNEXPECTED_ERROR.getValue());
+ }
+ }
+
+ public void run(String snapshotFileName, String startingNode, int maxDepth) throws IOException {
+ PathUtils.validatePath(startingNode);
+ if (maxDepth < 0) {
+ throw new IllegalArgumentException("max_depth must be a non-negative integer.");
+ }
+ String error = ZKUtil.validateFileInput(snapshotFileName);
+ if (error != null) {
+ throw new IllegalArgumentException(error);
+ }
+ DataTree dataTree = SnapshotComparer.getSnapshot(new File(snapshotFileName));
+ if (dataTree.getNode(startingNode) == null) {
+ throw new IllegalArgumentException("Starting node does not exist: " + startingNode);
+ }
+ StringBuilder builder = new StringBuilder();
+ printZnode(dataTree, startingNode, builder, 0, maxDepth);
+ System.out.println(builder);
+ }
+
+ private long[] printZnode(DataTree dataTree, String name, StringBuilder builder, int level, int maxDepth) {
+ DataNode node = dataTree.getNode(name);
+ Set children;
+ long dataSize;
+ synchronized (node) {
+ dataSize = node.data == null ? 0 : node.data.length;
+ children = new TreeSet<>(node.getChildren());
+ }
+ long[] result = {1L, dataSize};
+ if (children.isEmpty()) {
+ return result;
+ }
+ StringBuilder childBuilder = new StringBuilder();
+ for (String child : children) {
+ long[] childResult = printZnode(dataTree, name + (name.equals("/") ? "" : "/") + child,
+ childBuilder, level + 1, maxDepth);
+ result[0] += childResult[0];
+ result[1] += childResult[1];
+ }
+ if (maxDepth == 0 || level <= maxDepth) {
+ String indent = String.join("", Collections.nCopies(level, "--"));
+ builder.append(indent).append(" ").append(name).append("\n");
+ builder.append(indent).append(" children: ").append(result[0] - 1).append("\n");
+ builder.append(indent).append(" data: ").append(result[1]).append("\n");
+ builder.append(childBuilder);
+ }
+ return result;
+ }
+
+ public static String getUsage() {
+ String newLine = System.lineSeparator();
+ return String.join(newLine,
+ "USAGE:",
+ "",
+ "SnapshotRecursiveSummary ",
+ "",
+ "snapshot_file: path to the zookeeper snapshot",
+ "starting_node: the absolute path in the zookeeper tree where traversal should begin",
+ "max_depth: non-negative output depth. 0 displays every non-leaf node; "
+ + "1 displays the starting node and its non-leaf children; 2 adds another level, and so on. "
+ + "This ONLY affects the level of details displayed, NOT the calculation.");
+ }
+
+}
diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotComparerTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotComparerTest.java
new file mode 100644
index 00000000000..f44c75893b2
--- /dev/null
+++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotComparerTest.java
@@ -0,0 +1,434 @@
+/*
+ * 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.zookeeper.server;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.not;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+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.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import org.apache.jute.BinaryInputArchive;
+import org.apache.zookeeper.ZKTestCase;
+import org.apache.zookeeper.ZooDefs;
+import org.apache.zookeeper.server.persistence.FileSnap;
+import org.apache.zookeeper.server.persistence.SnapStream;
+import org.apache.zookeeper.server.persistence.SnapStream.StreamMode;
+import org.apache.zookeeper.test.ClientBase;
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SnapshotComparerTest extends ZKTestCase {
+
+ private SnapshotToolTestSupport support;
+
+ @Before
+ public void setUp() throws IOException {
+ support = new SnapshotToolTestSupport();
+ }
+
+ @After
+ public void tearDown() throws IOException {
+ support.close();
+ }
+
+ @Test
+ public void testAddedAndDeletedSubtrees() throws Exception {
+ DataTree left = new DataTree();
+ support.addNode(left, "/app", new byte[1]);
+ support.addNode(left, "/app/removed", new byte[2]);
+ support.addNode(left, "/app/removed/leaf", new byte[3]);
+ DataTree right = new DataTree();
+ support.addNode(right, "/app", new byte[1]);
+ support.addNode(right, "/app/added", new byte[4]);
+ support.addNode(right, "/app/added/a", new byte[5]);
+ support.addNode(right, "/app/added/b", null);
+
+ String output = compare(left, right, "0", "0", "-d");
+
+ assertThat(output, containsString("Node found in both trees. Delta: 4 bytes, 1 descendants"));
+ assertThat(output, containsString("Node /app found in both trees. Delta: 4 bytes, 1 descendants"));
+ assertThat(output, containsString("Node /app/removed found only in left tree. Descendant size: 5. Descendant count: 1"));
+ assertThat(output, containsString("Node /app/removed/leaf found only in left tree. Descendant size: 3. Descendant count: 0"));
+ assertThat(output, containsString("Node /app/added found only in right tree. Descendant size: 9. Descendant count: 2"));
+ assertThat(output, containsString("Node /app/added/a found only in right tree. Descendant size: 5. Descendant count: 0"));
+ assertThat(output, containsString("Filtered right node /app/added/b of size 0"));
+ assertTrue(output, output.indexOf("Node /app/added found") < output.indexOf("Node /app/removed found"));
+ assertThat(output, containsString("All layers compared."));
+ }
+
+ @Test
+ public void testSignedByteDeltas() throws Exception {
+ DataTree left = new DataTree();
+ support.addNode(left, "/grow", new byte[1]);
+ support.addNode(left, "/shrink", new byte[4]);
+ DataTree right = new DataTree();
+ support.addNode(right, "/grow", new byte[4]);
+ support.addNode(right, "/shrink", new byte[1]);
+
+ String output = compare(left, right, "0", "2147483647");
+
+ assertThat(output, containsString("Node /grow found in both trees. Delta: 3 bytes, 0 descendants"));
+ assertThat(output, containsString("Node /shrink found in both trees. Delta: -3 bytes, 0 descendants"));
+ }
+
+ @Test
+ public void testByteThresholdIsExclusive() throws Exception {
+ DataTree left = new DataTree();
+ support.addNode(left, "/node", new byte[1]);
+ DataTree right = new DataTree();
+ support.addNode(right, "/node", new byte[5]);
+
+ String filtered = compare(left, right, "4", "0", "--debug");
+ String included = compare(left, right, "3", "0");
+
+ assertThat(filtered, containsString("Filtered node /node of left size 1, right size 5"));
+ assertThat(filtered, not(containsString("Node /node found")));
+ assertThat(included, containsString("Node /node found in both trees. Delta: 4 bytes, 0 descendants"));
+ }
+
+ @Test
+ public void testDescendantThresholdWithNullData() throws Exception {
+ DataTree left = new DataTree();
+ support.addNode(left, "/app", null);
+ DataTree right = new DataTree();
+ support.addNode(right, "/app", null);
+ support.addNode(right, "/app/child", null);
+
+ String filtered = compare(left, right, "2147483647", "1", "-d");
+ String included = compare(left, right, "2147483647", "0");
+ String removed = compare(right, left, "2147483647", "0");
+
+ assertThat(filtered, containsString("Filtered node /app of left size 0, right size 0"));
+ assertThat(filtered, not(containsString("Node /app found")));
+ assertThat(included, containsString("Node /app found in both trees. Delta: 0 bytes, 1 descendants"));
+ assertThat(removed, containsString("Node /app found in both trees. Delta: 0 bytes, -1 descendants"));
+ }
+
+ @Test
+ public void testEphemeralNodesAreIncluded() throws Exception {
+ DataTree right = new DataTree();
+ right.createNode("/ephemeral", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1);
+
+ String output = compare(new DataTree(), right, "0", "0");
+
+ assertThat(output, containsString("Node /ephemeral found only in right tree. Descendant size: 7. Descendant count: 0"));
+ assertThat(output, containsString("Node found in both trees. Delta: 7 bytes, 1 descendants"));
+ }
+
+ @Test
+ public void testEqualLengthPayloadsAreOnlySizeCompared() throws Exception {
+ DataTree left = new DataTree();
+ support.addNode(left, "/equal", "aa".getBytes(StandardCharsets.UTF_8));
+ DataTree right = new DataTree();
+ support.addNode(right, "/equal", "bb".getBytes(StandardCharsets.UTF_8));
+
+ String output = compare(left, right, "0", "0", "-d");
+
+ assertThat(output, containsString("Filtered node /equal of left size 2, right size 2"));
+ assertThat(output, not(containsString("Node /equal found")));
+ assertThat(output, containsString("All layers compared."));
+ }
+
+ @Test
+ public void testMixedCompressionFormats() throws Exception {
+ DataTree leftTree = new DataTree();
+ support.addNode(leftTree, "/node", new byte[1]);
+ DataTree rightTree = new DataTree();
+ support.addNode(rightTree, "/node", new byte[3]);
+ File left = support.snapshot(leftTree, StreamMode.CHECKED, true);
+ for (StreamMode mode : StreamMode.values()) {
+ File right = support.snapshot(rightTree, mode, true);
+ String output = run("", arguments(left, right, "0", "0")).success();
+ assertThat(output, containsString("Node /node found in both trees. Delta: 2 bytes, 0 descendants"));
+ }
+ }
+
+ @Test
+ public void testSnapshotsWithoutDigest() throws Exception {
+ DataTree rightTree = new DataTree();
+ support.addNode(rightTree, "/node", new byte[3]);
+ File left = support.snapshot(new DataTree(), StreamMode.CHECKED, false);
+ File right = support.snapshot(rightTree, StreamMode.CHECKED, false);
+
+ String output = run("", arguments(left, right, "0", "0")).success();
+
+ assertThat(output, containsString("Node /node found only in right tree. Descendant size: 3. Descendant count: 0"));
+ }
+
+ @Test
+ public void testInteractivePathsAndDepths() throws Exception {
+ DataTree leftTree = new DataTree();
+ support.addNode(leftTree, "/app", null);
+ support.addNode(leftTree, "/app/leaf", new byte[1]);
+ DataTree rightTree = new DataTree();
+ support.addNode(rightTree, "/app", null);
+ support.addNode(rightTree, "/app/leaf", new byte[3]);
+ File left = support.snapshot(leftTree, StreamMode.CHECKED, true);
+ File right = support.snapshot(rightTree, StreamMode.CHECKED, true);
+
+ String output = run("/\n/app\n/app/leaf\n/missing\n-1\n999\nbad\n1\n\n\n",
+ arguments(left, right, "0", "0", "--interactive")).success();
+
+ assertThat(output, containsString("Analysis for node /"));
+ assertThat(output, not(containsString("Path / is neither found")));
+ assertThat(output, containsString("Node /app found in both trees. Delta: 2 bytes, 0 descendants"));
+ assertThat(output, containsString("Node /app/leaf found in both trees. Delta: 2 bytes, 0 descendants"));
+ assertThat(output, not(containsString("Path /app/leaf is neither found")));
+ assertThat(output, containsString("Path /missing is neither found in left tree nor right tree."));
+ assertThat(output, containsString("Depth must be in range [0, 2]"));
+ assertThat(output, containsString("Input bad is not valid."));
+ assertThat(output, containsString("Analysis for depth 1"));
+ assertThat(output, containsString("All layers compared."));
+ }
+
+ @Test
+ public void testInteractiveEndOfInput() throws Exception {
+ File snapshot = support.snapshot(new DataTree(), StreamMode.CHECKED, true);
+
+ String output = run("", arguments(snapshot, snapshot, "0", "0", "-i")).success();
+
+ assertThat(output, containsString("End of input."));
+ assertThat(output, not(containsString("All layers compared.")));
+ }
+
+ @Test
+ public void testInvalidArguments() throws Exception {
+ File snapshot = support.snapshot(new DataTree(), StreamMode.CHECKED, true);
+ run("").invalidInvocation();
+ run("", "-l", snapshot.toString()).invalidInvocation();
+ for (String invalid : Arrays.asList("-1", "NaN", "2147483648")) {
+ run("", arguments(snapshot, snapshot, invalid, "0")).invalidInvocation();
+ run("", arguments(snapshot, snapshot, "0", invalid)).invalidInvocation();
+ }
+ run("", arguments(snapshot, snapshot, "0", "0", "--unknown")).invalidInvocation();
+ run("", arguments(snapshot, snapshot, "0", "0", "extra")).invalidInvocation();
+ }
+
+ @Test
+ public void testInvalidFiles() throws Exception {
+ File snapshot = support.snapshot(new DataTree(), StreamMode.CHECKED, true);
+ File missing = new File(support.directory, "missing");
+ for (File invalid : Arrays.asList(missing, support.directory)) {
+ run("", arguments(invalid, snapshot, "0", "0")).invalidInvocation();
+ run("", arguments(snapshot, invalid, "0", "0")).invalidInvocation();
+ }
+ }
+
+ @Test
+ public void testCorruptSnapshots() throws Exception {
+ File valid = support.snapshot(new DataTree(), StreamMode.CHECKED, true);
+ for (File corrupt : support.corruptSnapshots()) {
+ SnapshotToolTestSupport.Result result = run("", arguments(corrupt, valid, "0", "0"));
+ result.readFailure();
+ assertThat(result.output, not(containsString("All layers compared.")));
+ }
+ }
+
+ @Test
+ public void testShellLauncher() throws Exception {
+ Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"));
+ DataTree rightTree = new DataTree();
+ support.addNode(rightTree, "/node", new byte[3]);
+ File left = support.snapshot(new DataTree(), StreamMode.CHECKED, true);
+ File right = support.snapshot(rightTree, StreamMode.GZIP, true);
+ for (String flags : Arrays.asList("", "-Xms32m -Xmx128m")) {
+ String output = support.runLauncher("zkSnapshotComparer.sh", flags,
+ "-l", left.toString(), "-r", right.toString(), "-b", "0", "-n", "0").success();
+ assertThat(output, containsString("Node /node found only in right tree. Descendant size: 3. Descendant count: 0"));
+ }
+ }
+
+ private String compare(DataTree left, DataTree right, String bytes, String nodes, String... flags) throws Exception {
+ return run("", arguments(support.snapshot(left, StreamMode.CHECKED, true),
+ support.snapshot(right, StreamMode.CHECKED, true),
+ bytes, nodes, flags)).success();
+ }
+
+ private String[] arguments(File left, File right, String bytes, String nodes, String... flags) {
+ List args = new ArrayList<>(Arrays.asList("--left", left.toString(), "--right", right.toString(),
+ "--bytes", bytes, "--nodes", nodes));
+ args.addAll(Arrays.asList(flags));
+ return args.toArray(new String[0]);
+ }
+
+ private SnapshotToolTestSupport.Result run(String input, String... args) throws Exception {
+ return support.run("org.apache.zookeeper.server.SnapshotComparer", input, args);
+ }
+
+ static class SnapshotToolTestSupport {
+
+ final File directory = ClientBase.createEmptyTestDir();
+ private int nextFile;
+
+ SnapshotToolTestSupport() throws IOException {
+ }
+
+ void addNode(DataTree tree, String path, byte[] data) throws Exception {
+ tree.createNode(path, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, 0, -1, 1, 1);
+ }
+
+ File snapshot(DataTree tree, StreamMode mode, boolean digest) throws IOException {
+ File snapshots = new File(directory, "snapshots with spaces");
+ assertTrue(snapshots.isDirectory() || snapshots.mkdir());
+ File file = new File(snapshots, "snapshot." + Integer.toHexString(++nextFile) + mode.getFileExtension());
+ StreamMode previousMode = SnapStream.getStreamMode();
+ boolean previousDigest = ZooKeeperServer.isDigestEnabled();
+ try {
+ SnapStream.setStreamMode(mode);
+ ZooKeeperServer.setDigestEnabled(digest);
+ new FileSnap(null).serialize(tree, Collections.singletonMap(0x123L, 30000), file, false);
+ } finally {
+ SnapStream.setStreamMode(previousMode);
+ ZooKeeperServer.setDigestEnabled(previousDigest);
+ }
+ return file;
+ }
+
+ List corruptSnapshots() throws IOException {
+ List files = new ArrayList<>();
+ File badHeader = snapshot(new DataTree(), StreamMode.CHECKED, true);
+ byte[] bytes = Files.readAllBytes(badHeader.toPath());
+ bytes[0] ^= 1;
+ Files.write(badHeader.toPath(), bytes);
+ files.add(badHeader);
+
+ File truncated = snapshot(new DataTree(), StreamMode.CHECKED, true);
+ bytes = Files.readAllBytes(truncated.toPath());
+ Files.write(truncated.toPath(), Arrays.copyOf(bytes, bytes.length / 2));
+ files.add(truncated);
+
+ File checksum = snapshot(new DataTree(), StreamMode.CHECKED, true);
+ bytes = Files.readAllBytes(checksum.toPath());
+ ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+ new FileSnap(null).deserialize(new DataTree(), new HashMap(),
+ BinaryInputArchive.getArchive(input));
+ int sealOffset = bytes.length - input.available();
+ bytes[sealOffset] ^= 1;
+ Files.write(checksum.toPath(), bytes);
+ files.add(checksum);
+
+ File partialDigest = snapshot(new DataTree(), StreamMode.CHECKED, true);
+ bytes = Files.readAllBytes(partialDigest.toPath());
+ Files.write(partialDigest.toPath(), Arrays.copyOf(bytes, sealOffset + 14));
+ files.add(partialDigest);
+
+ File digestSeal = snapshot(new DataTree(), StreamMode.CHECKED, true);
+ bytes = Files.readAllBytes(digestSeal.toPath());
+ bytes[bytes.length - 13] ^= 1;
+ Files.write(digestSeal.toPath(), bytes);
+ files.add(digestSeal);
+
+ File gzipTrailer = snapshot(new DataTree(), StreamMode.GZIP, true);
+ bytes = Files.readAllBytes(gzipTrailer.toPath());
+ bytes[bytes.length - 1] ^= 1;
+ Files.write(gzipTrailer.toPath(), bytes);
+ files.add(gzipTrailer);
+ return files;
+ }
+
+ Result run(String mainClass, String input, String... args) throws Exception {
+ List command = new ArrayList<>(Arrays.asList(
+ new File(System.getProperty("java.home"), "bin/java").toString(),
+ "-Xmx128m",
+ "-Djava.io.tmpdir=" + System.getProperty("java.io.tmpdir"),
+ "-cp",
+ System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
+ mainClass));
+ command.addAll(Arrays.asList(args));
+ return runProcess(new ProcessBuilder(command), input);
+ }
+
+ Result runLauncher(String launcher, String flags, String... args) throws Exception {
+ File script = new File(System.getProperty("basedir", "."), "../bin/" + launcher);
+ List command = new ArrayList<>(Arrays.asList("bash", script.getCanonicalPath()));
+ command.addAll(Arrays.asList(args));
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.environment().put("JAVA_HOME", System.getProperty("java.home"));
+ builder.environment().put("CLASSPATH",
+ System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")));
+ builder.environment().put("JVMFLAGS", flags);
+ return runProcess(builder, "");
+ }
+
+ private Result runProcess(ProcessBuilder builder, String input) throws Exception {
+ File outputFile = new File(directory, "output-" + ++nextFile);
+ File inputFile = new File(directory, "input-" + nextFile);
+ Files.write(inputFile.toPath(), input.getBytes(StandardCharsets.UTF_8));
+ Process process = builder
+ .redirectInput(inputFile)
+ .redirectErrorStream(true)
+ .redirectOutput(outputFile)
+ .start();
+ try {
+ assertTrue("CLI did not exit: " + builder.command(), process.waitFor(30, TimeUnit.SECONDS));
+ return new Result(process.exitValue(),
+ new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8));
+ } finally {
+ if (process.isAlive()) {
+ process.destroyForcibly();
+ }
+ }
+ }
+
+ void close() throws IOException {
+ FileUtils.deleteDirectory(directory);
+ }
+
+ static class Result {
+
+ final int exitCode;
+ final String output;
+
+ Result(int exitCode, String output) {
+ this.exitCode = exitCode;
+ this.output = output;
+ }
+
+ String success() {
+ assertEquals(output, ExitCode.EXECUTION_FINISHED.getValue(), exitCode);
+ return output;
+ }
+
+ void invalidInvocation() {
+ assertEquals(output, ExitCode.INVALID_INVOCATION.getValue(), exitCode);
+ assertThat(output.toLowerCase(), containsString("usage:"));
+ }
+
+ void readFailure() {
+ assertEquals(output, ExitCode.UNEXPECTED_ERROR.getValue(), exitCode);
+ assertThat(output, containsString("Unable to read snapshot"));
+ }
+ }
+ }
+
+}
diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java
new file mode 100644
index 00000000000..2e326ee1fc6
--- /dev/null
+++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/SnapshotRecursiveSummaryTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.zookeeper.server;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.not;
+import static org.junit.Assert.assertThat;
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import org.apache.zookeeper.ZKTestCase;
+import org.apache.zookeeper.ZooDefs;
+import org.apache.zookeeper.server.SnapshotComparerTest.SnapshotToolTestSupport;
+import org.apache.zookeeper.server.persistence.SnapStream.StreamMode;
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SnapshotRecursiveSummaryTest extends ZKTestCase {
+
+ private SnapshotToolTestSupport support;
+
+ @Before
+ public void setUp() throws IOException {
+ support = new SnapshotToolTestSupport();
+ }
+
+ @After
+ public void tearDown() throws IOException {
+ support.close();
+ }
+
+ @Test
+ public void testRecursiveSubtreeTotals() throws Exception {
+ String output = summarize(tree(), "/app", "0");
+
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ assertThat(output, containsString("-- /app/branch\n-- children: 2\n-- data: 8"));
+ assertThat(output, containsString("---- /app/branch/deep\n---- children: 1\n---- data: 5"));
+ assertThat(output, not(containsString(" /app/leaf\n")));
+ assertThat(output, not(containsString(" /app/empty\n")));
+ assertThat(output, not(containsString(" /zookeeper\n")));
+ }
+
+ @Test
+ public void testDepthOnlyLimitsOutput() throws Exception {
+ String output = summarize(tree(), "/app", "1");
+
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ assertThat(output, containsString("-- /app/branch\n-- children: 2\n-- data: 8"));
+ assertThat(output, not(containsString(" /app/branch/deep\n")));
+ }
+
+ @Test
+ public void testRootIncludesItsOwnData() throws Exception {
+ DataTree tree = tree();
+ tree.setData("/", new byte[11], 1, 1, 1);
+
+ String output = summarize(tree, "/", "1");
+
+ assertThat(output, containsString(" /\n children: 9\n data: 28"));
+ assertThat(output, containsString("-- /app\n-- children: 5\n-- data: 17"));
+ assertThat(output, containsString("-- /zookeeper\n-- children: 2\n-- data: 0"));
+ }
+
+ @Test
+ public void testSelectedLeafIsNotPrinted() throws Exception {
+ String output = summarize(tree(), "/app/leaf", "0");
+
+ assertThat(output, not(containsString("children:")));
+ assertThat(output, not(containsString("data:")));
+ }
+
+ @Test
+ public void testNullDataContributesZeroBytes() throws Exception {
+ DataTree tree = new DataTree();
+ support.addNode(tree, "/app", null);
+ support.addNode(tree, "/app/leaf", null);
+
+ String output = summarize(tree, "/app", "0");
+
+ assertThat(output, containsString(" /app\n children: 1\n data: 0"));
+ }
+
+ @Test
+ public void testEphemeralDescendantsAreIncluded() throws Exception {
+ DataTree tree = new DataTree();
+ support.addNode(tree, "/app", new byte[2]);
+ tree.createNode("/app/ephemeral", new byte[7], ZooDefs.Ids.OPEN_ACL_UNSAFE, 0x123L, -1, 1, 1);
+
+ String output = summarize(tree, "/app", "0");
+
+ assertThat(output, containsString(" /app\n children: 1\n data: 9"));
+ }
+
+ @Test
+ public void testCompressedSnapshots() throws Exception {
+ for (StreamMode mode : StreamMode.values()) {
+ File snapshot = support.snapshot(tree(), mode, true);
+ String output = run(snapshot.toString(), "/app", "0").success();
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ }
+ }
+
+ @Test
+ public void testSnapshotWithoutDigest() throws Exception {
+ File snapshot = support.snapshot(tree(), StreamMode.CHECKED, false);
+
+ String output = run(snapshot.toString(), "/app", "0").success();
+
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ }
+
+ @Test
+ public void testInvalidArguments() throws Exception {
+ File snapshot = support.snapshot(tree(), StreamMode.CHECKED, true);
+ run().invalidInvocation();
+ run(snapshot.toString(), "/app").invalidInvocation();
+ run(snapshot.toString(), "/app", "0", "extra").invalidInvocation();
+ for (String invalid : Arrays.asList("-1", "NaN", "2147483648")) {
+ run(snapshot.toString(), "/app", invalid).invalidInvocation();
+ }
+ }
+
+ @Test
+ public void testInvalidZnodePaths() throws Exception {
+ File snapshot = support.snapshot(tree(), StreamMode.CHECKED, true);
+ for (String invalid : Arrays.asList("", "app", "/app/", "/app//leaf", "/app/..", "/missing")) {
+ SnapshotToolTestSupport.Result result = run(snapshot.toString(), invalid, "0");
+ result.invalidInvocation();
+ assertThat(result.output, not(containsString("NullPointerException")));
+ }
+ }
+
+ @Test
+ public void testInvalidFiles() throws Exception {
+ run(new File(support.directory, "missing").toString(), "/", "0").invalidInvocation();
+ run(support.directory.toString(), "/", "0").invalidInvocation();
+ }
+
+ @Test
+ public void testCorruptSnapshots() throws Exception {
+ for (File corrupt : support.corruptSnapshots()) {
+ SnapshotToolTestSupport.Result result = run(corrupt.toString(), "/", "0");
+ result.readFailure();
+ assertThat(result.output, not(containsString("children:")));
+ }
+ }
+
+ @Test
+ public void testShellLauncher() throws Exception {
+ Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"));
+ File snapshot = support.snapshot(tree(), StreamMode.GZIP, true);
+ for (String flags : Arrays.asList("", "-Xms32m -Xmx128m")) {
+ String output = support.runLauncher("zkSnapshotRecursiveSummaryToolkit.sh", flags,
+ snapshot.toString(), "/app", "0").success();
+ assertThat(output, containsString(" /app\n children: 5\n data: 17"));
+ }
+ }
+
+ private DataTree tree() throws Exception {
+ DataTree tree = new DataTree();
+ support.addNode(tree, "/app", new byte[2]);
+ support.addNode(tree, "/app/branch", new byte[3]);
+ support.addNode(tree, "/app/branch/deep", null);
+ support.addNode(tree, "/app/branch/deep/leaf", new byte[5]);
+ support.addNode(tree, "/app/empty", null);
+ support.addNode(tree, "/app/leaf", new byte[7]);
+ return tree;
+ }
+
+ private String summarize(DataTree tree, String path, String depth) throws Exception {
+ File snapshot = support.snapshot(tree, StreamMode.CHECKED, true);
+ return run(snapshot.toString(), path, depth).success();
+ }
+
+ private SnapshotToolTestSupport.Result run(String... args) throws Exception {
+ return support.run("org.apache.zookeeper.server.SnapshotRecursiveSummary", "", args);
+ }
+
+}
From e0adf9f30fb94560010881f6c8d78976a68fe89e Mon Sep 17 00:00:00 2001
From: Arkesh Mishra <118651144+arkmish@users.noreply.github.com>
Date: Fri, 25 Sep 2026 15:07:42 +0530
Subject: [PATCH 2/9] Add validated snapshot-only metadata export (D2)
Stream versioned node, namespace and true-session metadata without values or credential-bearing ACL IDs. Validate the selected source, enforce optional output budgets and publish a checksummed manifest atomically last.
Reuse the D1 native snapshot loader and expose iterative preorder traversal without changing the summary CLI. Keep capture and owner uncertainty explicit; do not replay transaction logs or infer recovery completeness.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
bin/zkOfflineAudit.cmd | 23 +
bin/zkOfflineAudit.sh | 29 +
.../main/resources/markdown/zookeeperTools.md | 267 ++++-
.../server/OfflineAuditExporter.java | 653 ++++++++++++
.../zookeeper/server/SnapshotComparer.java | 68 +-
.../server/SnapshotRecursiveSummary.java | 109 +-
.../server/OfflineAuditExporterTest.java | 939 ++++++++++++++++++
7 files changed, 2056 insertions(+), 32 deletions(-)
create mode 100644 bin/zkOfflineAudit.cmd
create mode 100755 bin/zkOfflineAudit.sh
create mode 100644 zookeeper-server/src/main/java/org/apache/zookeeper/server/OfflineAuditExporter.java
create mode 100644 zookeeper-server/src/test/java/org/apache/zookeeper/server/OfflineAuditExporterTest.java
diff --git a/bin/zkOfflineAudit.cmd b/bin/zkOfflineAudit.cmd
new file mode 100644
index 00000000000..b8adc0e3d3f
--- /dev/null
+++ b/bin/zkOfflineAudit.cmd
@@ -0,0 +1,23 @@
+@echo off
+REM Licensed to the Apache Software Foundation (ASF) under one or more
+REM contributor license agreements. See the NOTICE file distributed with
+REM this work for additional information regarding copyright ownership.
+REM The ASF licenses this file to You under the Apache License, Version 2.0
+REM (the "License"); you may not use this file except in compliance with
+REM the License. You may obtain a copy of the License at
+REM
+REM http://www.apache.org/licenses/LICENSE-2.0
+REM
+REM Unless required by applicable law or agreed to in writing, software
+REM distributed under the License is distributed on an "AS IS" BASIS,
+REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+REM See the License for the specific language governing permissions and
+REM limitations under the License.
+
+setlocal
+call "%~dp0zkEnv.cmd"
+
+set ZOOMAIN=org.apache.zookeeper.server.OfflineAuditExporter
+call %JAVA% -cp "%CLASSPATH%" %JVMFLAGS% %ZOOMAIN% %*
+
+endlocal & exit /b %ERRORLEVEL%
diff --git a/bin/zkOfflineAudit.sh b/bin/zkOfflineAudit.sh
new file mode 100755
index 00000000000..94625b018b3
--- /dev/null
+++ b/bin/zkOfflineAudit.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+# 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.
+
+ZOOBIN="${BASH_SOURCE-$0}"
+ZOOBIN="$(dirname "${ZOOBIN}")"
+ZOOBINDIR="$(cd "${ZOOBIN}"; pwd)"
+
+if [ -e "$ZOOBIN/../libexec/zkEnv.sh" ]; then
+ . "$ZOOBINDIR"/../libexec/zkEnv.sh
+else
+ . "$ZOOBINDIR"/zkEnv.sh
+fi
+
+"$JAVA" -cp "$CLASSPATH" $JVMFLAGS \
+ org.apache.zookeeper.server.OfflineAuditExporter "$@"
diff --git a/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md b/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md
index 7fc685d9e8f..0fa468e110a 100644
--- a/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md
+++ b/zookeeper-docs/src/main/resources/markdown/zookeeperTools.md
@@ -25,6 +25,7 @@ limitations under the License.
* [zkSnapShotToolkit.sh](#zkSnapShotToolkit)
* [zkSnapshotComparer.sh](#zkSnapshotComparer)
* [zkSnapshotRecursiveSummaryToolkit.sh](#zkSnapshotRecursiveSummaryToolkit)
+ * [zkOfflineAudit.sh](#zkOfflineAudit)
* [Testing](#Testing)
* [Jepsen Test](#jepsen-test)
@@ -304,7 +305,271 @@ Neither tool compares payload contents, ACLs, versions or other znode metadata.
**Equal sizes/counts do not prove identical contents.** Snapshots may be fuzzy:
these tools do not replay transaction logs, reconstruct point-in-time state,
or establish transaction-consistent equality. The tools load snapshots into
-memory, and recursive traversal still visits the full selected subtree.
+memory, and traversal still visits the full selected subtree.
+
+
+
+### zkOfflineAudit.sh
+
+Export versioned, non-value metadata from **one explicitly selected snapshot**.
+The Windows equivalent is `bin/zkOfflineAudit.cmd`; the Java entry point is
+`org.apache.zookeeper.server.OfflineAuditExporter`.
+
+```bash
+bin/zkOfflineAudit.sh --snapshot-file snapshot.100000001.gz --output-dir audit-run
+```
+
+The output directory **must not already exist**, even if empty, and its parent
+must exist. Use a new, owned directory for each invocation. Existing files,
+directories, hard links and symbolic links at the destination are rejected
+without modifying them. Filesystem path resolution preserves symbolic-link
+parent semantics; it does not lexically remove `..` and select a different file.
+The tool never renames, rewrites or deletes the source, connects to a ZooKeeper
+server, replays transaction logs, or selects a fallback snapshot.
+
+The shared native reader accepts format-version 2 snapshots, uncompressed or
+with the native `.gz`/`.snappy` suffix. It checks the header, body seal and any
+optional digest seal, rejects partial trailers, invalid roots and duplicate node
+records, and consumes the decoded stream to its end. A digest is recorded as metadata, **not** compared
+as a transaction-consistent recovery digest. Fuzzy snapshots do not need
+transaction-log coverage for this export.
+
+#### Resources, publication and failures
+
+The native reader loads one complete `DataTree`, its ACL cache and session
+indexes. Budget heap for the **decoded** tree, not just the compressed file or
+stored value bytes. The exporter adds an iterative traversal stack, sorted child
+name references for the active ancestors, one JSON record at a time, and a copy
+of one session's ephemeral path set at a time. It does not build a second tree
+or retain all output records. Native quota initialization can still recurse
+during loading; an unusually deep quota tree can exhaust the JVM stack.
+
+Output can exceed snapshot size substantially: every node includes full paths
+and metadata, and deep paths are repeated. Disk policy must also allow filesystem
+allocation overhead. The optional `--max-output-bytes BYTES` is a **positive,
+decimal signed-64-bit integer** bounding the combined UTF-8 bytes of the three
+NDJSON files and the manifest, including newlines. No production budget is
+assumed when it is absent. This is not an input-size, heap, stack, runtime or
+filesystem-block quota. Supply approved worker budgets and JVM limits through
+the existing launcher environment; do not infer safe limits from this example.
+
+The tool creates a private directory (mode 0700 on POSIX), creates output files
+without replacement, closes and forces the data files, stages the manifest,
+rechecks source identity, and **atomically publishes `manifest.json` last**.
+Atomic move support is required; there is no non-atomic fallback. Source checks
+compare SHA-256 of the original file bytes, resolved path, size, precise mtime,
+creation time, available file key and Unix change time where supported, including
+stat checks around hashing. Access time is deliberately not compared because
+reading may update it. Use immutable, owned input copies: these checks are not
+a lock against concurrent writers, and identity/time precision depends on the
+filesystem.
+
+Exit 0 means publication succeeded. Invalid arguments, decoder properties or an
+existing destination exit 2 with usage; snapshot, source-change, output-budget
+and I/O failures exit 1 with diagnostics. JVM resource errors remain visible
+and nonzero rather than becoming an empty-database success. A failed started
+export can leave partial files and `.manifest.json.inprogress` for diagnosis;
+**neither is a published result** and the directory cannot be reused. An
+existing destination is rejected *before an export is attempted*: its prior
+manifest is left untouched, not relabeled as this invocation's result. Consumers
+must require a successful invocation into their unique run directory, read only
+`manifest.json`, and verify its source identity, schema, file counts and hashes.
+The file descriptors omit the manifest itself to avoid a checksum cycle.
+
+#### Decoder configuration and owner uncertainty
+
+Use JVM system properties (for example through `JVMFLAGS`) only when the source
+settings are known:
+
+* `zookeeper.extendedTypesEnabled`: `true` or `false`, case-insensitive.
+* `zookeeper.emulate353TTLNodes`: `true` or `false`, case-insensitive. Explicit
+ `true` requires explicit `zookeeper.extendedTypesEnabled=true`.
+* `jute.maxbuffer`: positive 32-bit integer in the native Java numeric-property
+ syntax (`Integer.decode`: decimal, `0x`/`#` hexadecimal or leading-zero octal).
+ When absent, the native `BinaryInputArchive.maxBuffer` default applies.
+* `zookeeper.jute.maxbuffer.extrasize`: non-negative 32-bit integer in the same
+ syntax. When absent it defaults to `jute.maxbuffer`; the native minimum extra
+ padding of 1024 bytes is applied. The effective sum must fit a signed 32-bit
+ length. These decoder bounds are not estimates of server response limits.
+
+Malformed properties are rejected instead of silently using Java's fallback.
+Compression is detected by the selected file's suffix, not the output
+compression system property. The optional digest is read regardless of the
+local digest-calculation setting. Arbitrary JVM properties are not exported.
+
+Snapshots do not persist the source's type settings. Absent source properties
+are therefore `null` in the manifest, although the native decoder's effective
+boolean defaults are false. Owner 0 is persistent, `0x8000000000000000` is a
+container, and nonzero sign-bit-clear owners are normal ephemerals. Other owners
+are classified as follows:
+
+| Known source configuration | Classification of other negative owners |
+| --- | --- |
+| Extended types unspecified | `unknown`; never grouped as sessions |
+| Extended types explicitly false | Native normal ephemeral/session |
+| Extended types true, emulation unspecified | `unknown`; legacy interpretation is unresolved |
+| Extended types true, emulation false | Native modern TTL or normal ephemeral; unsupported extended feature bits fail |
+| Extended types true, emulation true | Native 3.5.3-emulated TTL |
+
+`ttl_ms` uses this decoder's `EphemeralType.TTL.getValue(owner)` (low 40 bits),
+including emulation; it does not reconstruct a historical implementation's
+different value mask. It is a duration, **not** a calculated expiry time.
+Neither an owner in `sessions.ndjson` nor membership in the snapshot session
+table proves a currently live session.
+
+#### Schema version 1
+
+All files are UTF-8. Each NDJSON line is one complete JSON object; `manifest.json`
+is one JSON object. `null` means unknown/not applicable, not zero. `integer`
+below means a JSON integral number (64-bit arithmetic for counts, lengths,
+byte totals and times); schema/format/digest versions, node version counters,
+session timeouts and quota counts are 32-bit integers. No field is a JVM-memory
+estimate. **All 64-bit zxids, owner/session identifiers and digest values are
+lowercase, zero-padded, 16-digit hex strings prefixed with `0x`**, preserving
+their unsigned bit patterns. SHA-256 values are 64 lowercase hex digits without
+a prefix. Consumers must not convert identifiers to floating-point numbers.
+
+**`nodes.ndjson`** includes the root exactly once as `/`, all customer nodes and
+the reserved subtree. Records are emitted in deterministic depth-first
+**preorder** with sorted children. Each parent's descendants are contiguous,
+supporting streaming subtree intervals. This is not a global lexical path sort:
+`/a/x` follows `/a` before the traversal advances to `/a-`.
+
+| Field | Type | Exact meaning |
+| --- | --- | --- |
+| `schema_version` | integer | `1` |
+| `path` | string | Full absolute decoded znode path; root is `/`, never `""` |
+| `data_length` | integer | This decoded node's payload length in bytes; null data contributes 0 |
+| `num_children` | integer | Number of **immediate** children, not descendants |
+| `getchildren_response_bytes` | integer | Full successful getChildren reply, defined below |
+| `getchildren2_response_bytes` | integer | Same reply plus the 68-byte Stat |
+| `ctime_ms`, `mtime_ms` | integer | Stored node creation/data-modification timestamps, epoch milliseconds |
+| `czxid`, `mzxid`, `pzxid` | hex string | Stored creation, data-modification and child-modification zxids |
+| `data_version`, `acl_version` | integer | Persisted data/ACL version counters |
+| `persisted_cversion` | integer | Raw persisted child-create counter; not the client Stat cversion conversion |
+| `path_utf8_bytes` | integer | Full absolute path's UTF-8 length, including leading slash, without a length prefix |
+| `node_type` | string | `persistent`, `ephemeral`, `container`, `ttl` or `unknown` |
+| `ephemeral_owner` | hex string | Raw persisted owner bits, including 0, TTL/container and unknown encodings |
+| `owner_encoding` | string | `none`, `session`, `container`, `ttl`, `ttl-3.5.3` or `unknown` |
+| `ttl_ms` | integer or null | Native decoded TTL duration for known TTL types only |
+| `acl_risk_flags` | array of strings | Non-secret review hints below; empty means no listed hint, not proven safe |
+
+The getChildren size is
+`16 + 4 + sum(4 + UTF-8(child-name).length)`: the 16-byte ReplyHeader, four-byte
+vector count, and four-byte length plus bytes for each **child name**, not full
+path. getChildren2 adds 68 bytes. Both **exclude** the outer four-byte framing
+prefix and transport/TLS overhead. Empty vectors are 20/88 bytes; names `é`, `a`
+are 31/99 bytes. Counts use `long` arithmetic, independent of any packet limit.
+Actual Jute serialization agrees for valid znode names. This branch's older
+Jute encoder treats surrogate pairs differently from standard UTF-8, but native
+`PathUtils` prohibits those characters in znode paths; malformed decoded paths
+are rejected, not silently relabeled.
+
+ACL hints do not disclose ACL scheme strings or IDs, change permissions, or
+perform authentication/authorization:
+
+| Flag | Meaning |
+| --- | --- |
+| `world_read` | `world:anyone` has READ |
+| `world_write` | `world:anyone` has any of WRITE, CREATE or DELETE |
+| `world_admin` | `world:anyone` has ADMIN |
+| `auth` | An `auth` scheme entry remains in the decoded ACL |
+| `unknown_scheme` | Scheme outside the known set `world`, `auth`, `digest`, `ip`, `sasl`, `x509`; custom providers require review |
+| `missing_acl` | Decoded ACL list is null or empty |
+| `invalid_permissions` | Permission bits outside `ZooDefs.Perms.ALL` |
+| `invalid_identity` | Missing identity/scheme/ID, or a `world` identity other than `anyone` |
+
+Native deserialization can reject malformed ACL structures before flags can be
+produced. Node values and raw ACL identities are never written to the export.
+Paths themselves can be sensitive; protect the output as metadata.
+
+**`namespaces.ndjson`** contains **every** immediate root child except exactly
+`/zookeeper`, including leaves and `/zookeeper-client`. Counts/bytes include the
+namespace node and all descendants of every node type. Root data is not assigned
+to a namespace. Records follow traversal order.
+
+| Field | Type | Exact meaning |
+| --- | --- | --- |
+| `schema_version` | integer | `1` |
+| `path` | string | Top-level customer namespace path |
+| `subtree_node_count` | integer | Inclusive subtree node count |
+| `payload_bytes` | integer | Sum of decoded payload lengths; null contributes 0 |
+| `path_utf8_bytes` | integer | Sum of full absolute path lengths in UTF-8 |
+| `quota_status` | string | `absent`, `valid` or `invalid` for the exact namespace limit znode |
+| `quota_limit_count` | integer or null | Parsed `count`, including -1 (unset); null if absent/invalid |
+| `quota_limit_bytes` | integer or null | Parsed `bytes`, including -1 (unset); null if absent/invalid |
+| `quota_stat_present` | boolean | Exact namespace quota stat znode exists; not evidence of freshness |
+
+Quota availability refers specifically to
+`/zookeeper/quota/zookeeper_limits` and `zookeeper_stats`; it does not
+claim absence of quotas on descendants or model inherited enforcement. A valid
+limit has the native `count=,bytes=` format with values at least -1.
+Malformed limits are flagged without exporting their raw value. Native DataTree
+loading **rebuilds quota-stat payloads in memory**; node lengths under the
+reserved quota subtree describe that decoded tree, not necessarily the original
+stat-node payload lengths. Customer subtree totals are computed independently
+from the decoded customer nodes; no rebuilt quota count is trusted for them.
+
+**`sessions.ndjson`** has one record per owner with decoded normal ephemerals.
+It excludes containers, known TTLs, unknown encodings and session-table entries
+with no normal ephemeral nodes. Session ordering is unspecified; records are
+streamed from the native index, including ephemerals in the reserved subtree.
+
+| Field | Type | Exact meaning |
+| --- | --- | --- |
+| `schema_version` | integer | `1` |
+| `session_id` | hex string | Normal ephemeral owner under the known/unambiguous decoder interpretation |
+| `ephemeral_node_count` | integer | Number of nodes owned by that session |
+| `payload_bytes` | integer | Sum of those nodes' payload lengths |
+| `path_utf8_bytes` | integer | Sum of those nodes' full UTF-8 path lengths |
+| `present_in_snapshot_session_table` | boolean | Owner also appears in the serialized session/timeout table |
+| `timeout_ms` | integer or null | Serialized timeout if present; null otherwise, not a remaining lifetime |
+
+**`manifest.json`** is the sole publication marker. Export completeness means
+complete traversal/output of this decoded snapshot, **not** current-state,
+backup completeness or transaction-consistent recovery.
+
+| Field | Type | Exact meaning |
+| --- | --- | --- |
+| `schema_version` | integer | `1` |
+| `tool` | object | `name` = `OfflineAuditExporter`; `version` = native `Version.getFullVersion()` string |
+| `export_id` | string | New UUID identifying this published export |
+| `export_complete` | boolean | `true` in a successfully published manifest only |
+| `recovery_scope` | string | Always `snapshot-only` |
+| `transaction_logs_replayed` | boolean | Always `false` |
+| `source_capture_time_ms` | null | Capture time unknown; never inferred from file or node timestamps |
+| `source_capture_provenance` | null | No authoritative capture provenance supplied to this CLI |
+| `source_server_version` | null | Snapshot header version does not identify the source server release |
+| `source` | object | Source descriptor defined below |
+| `decoder` | object | Decoder settings defined below |
+| `snapshot_zxid` | hex string or null | Label parsed only from `snapshot.<1..16 hex digits>[.gz or .snappy]`; renamed/range filenames yield null; not a consistent endpoint |
+| `last_observed_zxid` | hex string | Unsigned maximum of all exported nodes' `czxid`, `mzxid`, `pzxid`; excludes filename/digest zxids |
+| `last_observed_zxid_scope` | string | Always `maximum-node-czxid-mzxid-pzxid-unsigned` |
+| `snapshot_digest` | object or null | Optional stored digest metadata, not recovery evidence |
+| `unknown_owner_nodes` | integer | Count of node records with `node_type=unknown` |
+| `max_output_bytes` | integer or null | Requested output byte budget, or no CLI budget |
+| `files` | array of objects | Exactly three output descriptors, in nodes/namespaces/sessions order |
+
+Nested objects have these exact fields:
+
+* `source`: `path` (resolved absolute filename string), `sha256` (hash of the
+ original **compressed bytes**, if compressed), `size_bytes` (integer),
+ `mtime_ms` (integer epoch milliseconds), `change_time_ms` (Unix inode-change
+ time in epoch milliseconds, or null if unavailable), `compression` (`checked`
+ = uncompressed, `gzip` or `snappy`), `format_version` (integer, currently 2),
+ `database_id` (raw header dbid as a hex string). None of the file times is
+ snapshot capture time.
+* `decoder`: `source_extended_types_enabled` and `source_emulate_353_ttl_nodes`
+ (boolean or null); `effective_extended_types_enabled` and
+ `effective_emulate_353_ttl_nodes` (boolean); `jute_maxbuffer` and
+ `jute_extra_maxbuffer` (integers after native default/minimum handling).
+* `snapshot_digest`, when present: `zxid` (hex string), `version` (integer),
+ `value` (hex string), `seal_validated` (true),
+ `transaction_consistency_verified` (false). A native zero/empty digest is
+ retained as zero, not promoted to completeness evidence.
+* Each `files` entry: `name` (exactly `nodes.ndjson`, `namespaces.ndjson` or
+ `sessions.ndjson`), `records` (integer line count), `size_bytes` (integer
+ physical file length), `sha256` (hash of all file bytes, including newlines).
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/OfflineAuditExporter.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/OfflineAuditExporter.java
new file mode 100644
index 00000000000..ac2fbaebe0d
--- /dev/null
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/OfflineAuditExporter.java
@@ -0,0 +1,653 @@
+/*
+ * 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.zookeeper.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.BufferedOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.security.DigestOutputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.apache.jute.BinaryInputArchive;
+import org.apache.zookeeper.Quotas;
+import org.apache.zookeeper.StatsTrack;
+import org.apache.zookeeper.Version;
+import org.apache.zookeeper.ZKUtil;
+import org.apache.zookeeper.ZooDefs;
+import org.apache.zookeeper.data.ACL;
+import org.apache.zookeeper.data.Id;
+import org.apache.zookeeper.data.StatPersisted;
+import org.apache.zookeeper.server.SnapshotComparer.SnapshotData;
+import org.apache.zookeeper.server.persistence.SnapStream;
+import org.apache.zookeeper.util.ServiceUtils;
+
+/**
+ * Streams non-value metadata from one validated snapshot. No transaction logs are
+ * replayed and neither the snapshot filename nor its digest establishes capture time
+ * or transaction-consistent state. See zookeeperTools.md for the versioned schema.
+ */
+public final class OfflineAuditExporter {
+
+ private static final int SCHEMA_VERSION = 1;
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private static final Pattern SNAPSHOT_NAME = Pattern.compile("snapshot\\.([0-9a-fA-F]{1,16})(?:\\.(?:gz|snappy))?");
+ private static final Pattern QUOTA_VALUE = Pattern.compile("count=-?[0-9]+,bytes=-?[0-9]+");
+ private static final Set ACL_SCHEMES = new HashSet<>(Arrays.asList("world", "auth", "digest", "ip", "sasl", "x509"));
+
+ private OfflineAuditExporter() {
+ }
+
+ public static void main(String[] args) {
+ Options options = new Options();
+ options.addOption(Option.builder().longOpt("snapshot-file").hasArg().required().argName("SNAPSHOT")
+ .desc("Read only this snapshot file.").build());
+ options.addOption(Option.builder().longOpt("output-dir").hasArg().required().argName("DIRECTORY")
+ .desc("Create a new directory; its parent must already exist.").build());
+ options.addOption(Option.builder().longOpt("max-output-bytes").hasArg().argName("BYTES")
+ .desc("Optional positive byte budget, including the manifest. No default policy limit.").build());
+ Path source;
+ Path output;
+ Long limit;
+ DecoderSettings decoder;
+ try {
+ CommandLine command = new DefaultParser().parse(options, args);
+ if (!command.getArgList().isEmpty()) {
+ throw new IllegalArgumentException("Unexpected positional arguments");
+ }
+ Set supplied = new HashSet<>();
+ for (Option option : command.getOptions()) {
+ if (!supplied.add(option.getLongOpt())) {
+ throw new IllegalArgumentException("Repeated option: --" + option.getLongOpt());
+ }
+ }
+ limit = command.hasOption("max-output-bytes")
+ ? Long.valueOf(command.getOptionValue("max-output-bytes")) : null;
+ if (limit != null && limit <= 0) {
+ throw new IllegalArgumentException("max-output-bytes must be a positive 64-bit integer");
+ }
+ decoder = DecoderSettings.read();
+ source = Paths.get(command.getOptionValue("snapshot-file")).toAbsolutePath();
+ String error = ZKUtil.validateFileInput(source.toString());
+ if (error != null) {
+ throw new IllegalArgumentException(error);
+ }
+ output = Paths.get(command.getOptionValue("output-dir")).toAbsolutePath();
+ if (Files.exists(output, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IllegalArgumentException("Output already exists; no export attempted and prior files left untouched: " + output);
+ }
+ Path parent = output.getParent();
+ if (parent == null || !Files.isDirectory(parent)) {
+ throw new IllegalArgumentException("Output parent must be an existing directory: " + output);
+ }
+ output = parent.toRealPath().resolve(fileName(output));
+ } catch (ParseException | IllegalArgumentException | IOException e) {
+ System.err.println(e.getMessage());
+ new HelpFormatter().printHelp("zkOfflineAudit.sh", options, true);
+ ServiceUtils.requestSystemExit(ExitCode.INVALID_INVOCATION.getValue());
+ return;
+ }
+ try {
+ export(source, output, decoder, limit);
+ System.out.println("Snapshot-only export complete: " + output);
+ } catch (IOException | IllegalArgumentException | ArithmeticException e) {
+ System.err.println("Unable to export snapshot: " + e.getMessage());
+ ServiceUtils.requestSystemExit(ExitCode.UNEXPECTED_ERROR.getValue());
+ }
+ }
+
+ /**
+ * Returns the entire successful getChildren/getChildren2 reply packet size,
+ * including the 16-byte ReplyHeader but excluding the outer four-byte frame.
+ */
+ public static long childResponseBytes(Iterable children, boolean includeStat) {
+ long bytes = includeStat ? 88L : 20L;
+ for (String child : children) {
+ bytes = Math.addExact(bytes, 4L + child.getBytes(StandardCharsets.UTF_8).length);
+ }
+ return bytes;
+ }
+
+ private static void export(Path source, Path output, DecoderSettings decoder, Long limit) throws IOException {
+ SourceIdentity identity = SourceIdentity.read(source);
+ SnapshotData snapshot = SnapshotComparer.readSnapshot(identity.path.toFile());
+ if (output.getFileSystem().supportedFileAttributeViews().contains("posix")) {
+ Files.createDirectory(output, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")));
+ } else {
+ Files.createDirectory(output);
+ }
+ OutputBudget budget = new OutputBudget(limit);
+ ExportRecords records = new ExportRecords(snapshot, decoder);
+ List