This is an automated email from the ASF dual-hosted git repository. anmolnar pushed a commit to branch branch-3.9 in repository https://gitbox.apache.org/repos/asf/zookeeper.git
commit 0f7ce95f2fcd2906c9f2915506f0fc6ed972b8d8 Author: Andor Molnar <[email protected]> AuthorDate: Mon Sep 21 12:45:22 2026 -0500 Quota: protect quota-stats data with format validation --- .../src/main/java/org/apache/zookeeper/Quotas.java | 11 +- .../main/java/org/apache/zookeeper/StatsTrack.java | 74 ++++++++++++- .../java/org/apache/zookeeper/server/DataTree.java | 55 +++++++--- .../zookeeper/server/PrepRequestProcessor.java | 46 ++++++++ .../org/apache/zookeeper/server/DataTreeTest.java | 118 +++++++++++++++++++++ .../java/org/apache/zookeeper/test/QuotasTest.java | 14 +++ .../org/apache/zookeeper/test/StatsTrackTest.java | 86 +++++++++++++++ 7 files changed, 387 insertions(+), 17 deletions(-) diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/Quotas.java b/zookeeper-server/src/main/java/org/apache/zookeeper/Quotas.java index eb83cfea5..1292b5df2 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/Quotas.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/Quotas.java @@ -78,9 +78,18 @@ public static String statPath(String path) { * return the real path associated with this * quotaPath. * @param quotaPath the quotaPath which's started with /zookeeper/quota - * @return the real path associated with this quotaPath. + * @return the real path associated with this quotaPath, or an empty string + * if the given path is not actually under /zookeeper/quota. Returning + * "" rather than throwing keeps a stray {@code zookeeper_limits} node + * elsewhere under /zookeeper (or the /zookeeper/quota root itself) + * from raising StringIndexOutOfBoundsException on the + * transaction-apply path, which would kill the SyncRequestProcessor + * critical thread and leave the dataDir unbootable on replay. */ public static String trimQuotaPath(String quotaPath) { + if (quotaPath == null || !quotaPath.startsWith(quotaZookeeper)) { + return ""; + } return quotaPath.substring(quotaZookeeper.length()); } } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/StatsTrack.java b/zookeeper-server/src/main/java/org/apache/zookeeper/StatsTrack.java index 02fffb753..bd86422e7 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/StatsTrack.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/StatsTrack.java @@ -26,12 +26,16 @@ import java.util.Objects; import java.util.regex.Pattern; import org.apache.zookeeper.common.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * a class that represents the stats associated with quotas */ public class StatsTrack { + private static final Logger LOG = LoggerFactory.getLogger(StatsTrack.class); + private static final String countStr = "count"; private static final String countHardLimitStr = "countHardLimit"; @@ -54,7 +58,11 @@ public StatsTrack() { * @param stat the byte[] stat to be initialized with */ public StatsTrack(byte[] stat) { - this(new String(stat, StandardCharsets.UTF_8)); + // A null payload (jute length -1) must be tolerated here: this is on the + // transaction-apply/replay path, and new String(null, ...) would NPE, + // killing the SyncRequestProcessor critical thread and bricking the + // dataDir just like a malformed value. Treat null as empty/uninitialized. + this(stat == null ? null : new String(stat, StandardCharsets.UTF_8)); } /** @@ -70,9 +78,71 @@ public StatsTrack(String stat) { } String[] keyValuePairs = PAIRS_SEPARATOR.split(stat); for (String keyValuePair : keyValuePairs) { + // NOTE: split with the default (limit 0) is intentional and must be + // kept: toString() emits a compatibility sentinel trailing '=' (e.g. + // "bytes=5=") that the default split silently drops, recovering "5". + // Changing the limit would break the round-trip of hard-limit values. + String[] kv = keyValuePair.split("="); + if (kv.length < 2 || StringUtils.isEmpty(kv[0])) { + // Malformed entry (e.g. "x", or "count=" with no value). This + // data is applied from an already-committed transaction, so + // throwing here would kill the SyncRequestProcessor critical + // thread and, because the transaction is durable, make the + // dataDir unbootable on replay. Skip the entry defensively + // instead (treated as uninitialized, i.e. -1); updateQuotaStat + // rewrites the node with a well-formed value, so it self-heals. + LOG.warn("Ignoring malformed quota stat entry: '{}'", keyValuePair); + continue; + } + String value = StringUtils.isEmpty(kv[1]) ? "-1" : kv[1]; + try { + this.stats.put(kv[0], Long.parseLong(value)); + } catch (NumberFormatException e) { + LOG.warn("Ignoring quota stat entry with non-numeric value: '{}'", keyValuePair); + } + } + } + + /** + * Strictly validate that {@code data} is a well-formed stats/limits value, + * i.e. a (possibly empty) list of {@code key=long} pairs separated by + * {@code ,} or {@code ;}. Unlike the constructor, this performs no repair — + * it is used at request-preparation time to reject malformed writes to the + * quota {@code zookeeper_stats}/{@code zookeeper_limits} nodes before they + * are ever committed, so clients get a clear error and the quota bookkeeping + * never ingests garbage. + * + * @param data the candidate node data (UTF-8 encoded); empty is accepted + * (treated as uninitialized), null is rejected + * @return true if the data parses cleanly as StatsTrack content + */ + public static boolean isValidStatsData(byte[] data) { + if (data == null) { + // null is never legitimate for a stats/limits node and NPEs the + // byte[] constructor on the apply path — reject it at prep time. + return false; + } + if (data.length == 0) { + return true; + } + String stat = new String(data, StandardCharsets.UTF_8); + String[] keyValuePairs = PAIRS_SEPARATOR.split(stat); + for (String keyValuePair : keyValuePairs) { + // Same tokenization as the constructor so the compatibility sentinel + // trailing '=' (e.g. "bytes=5=") is accepted, not rejected. String[] kv = keyValuePair.split("="); - this.stats.put(kv[0], Long.parseLong(StringUtils.isEmpty(kv[1]) ? "-1" : kv[1])); + if (kv.length < 2 || StringUtils.isEmpty(kv[0])) { + return false; + } + if (!StringUtils.isEmpty(kv[1])) { + try { + Long.parseLong(kv[1]); + } catch (NumberFormatException e) { + return false; + } + } } + return true; } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java index aa9b7cee7..dbce6f90d 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/DataTree.java @@ -498,14 +498,25 @@ public void createNode(final String path, byte[] data, List<ACL> acl, long ephem } // now check if its one of the zookeeper node child if (parentName.startsWith(quotaZookeeper)) { - // now check if it's the limit node - if (Quotas.limitNode.equals(childName)) { - // this is the limit node - // get the parent and add it to the trie - pTrie.addPath(Quotas.trimQuotaPath(parentName)); - } - if (Quotas.statNode.equals(childName)) { - updateQuotaForPath(Quotas.trimQuotaPath(parentName)); + boolean isLimitNode = Quotas.limitNode.equals(childName); + boolean isStatNode = Quotas.statNode.equals(childName); + if (isLimitNode || isStatNode) { + // The quota namespace between /zookeeper/quota and the + // limit/stat node must be non-empty. A limit/stat node created + // directly under /zookeeper/quota trims to "", and + // PathTrie.addPath("") throws, killing the SyncRequestProcessor + // critical thread on the apply path (and unbootable on replay). + String quotaPrefix = Quotas.trimQuotaPath(parentName); + if (quotaPrefix.isEmpty()) { + LOG.warn("Ignoring quota {} node with empty namespace under {}", + isLimitNode ? "limit" : "stat", parentName); + } else if (isLimitNode) { + // this is the limit node + // get the parent and add it to the trie + pTrie.addPath(quotaPrefix); + } else { + updateQuotaForPath(quotaPrefix); + } } } @@ -588,10 +599,19 @@ public void deleteNode(String path, long zxid) throws NoNodeException { } } - if (parentName.startsWith(procZookeeper) && Quotas.limitNode.equals(childName)) { - // delete the node in the trie. - // we need to update the trie as well - pTrie.deletePath(Quotas.trimQuotaPath(parentName)); + // Only limit nodes actually under /zookeeper/quota are ever registered + // in the path trie (createNode uses the same prefix). Mirror that prefix + // here — using the wider /zookeeper prefix let a stray zookeeper_limits + // node elsewhere under /zookeeper reach trimQuotaPath and throw + // StringIndexOutOfBoundsException on the apply path. The empty-namespace + // case (node directly under /zookeeper/quota) is skipped too, since + // deletePath("") would likewise throw. + if (parentName.startsWith(quotaZookeeper) && Quotas.limitNode.equals(childName)) { + String quotaPrefix = Quotas.trimQuotaPath(parentName); + if (!quotaPrefix.isEmpty()) { + // delete the node in the trie; we need to update the trie as well + pTrie.deletePath(quotaPrefix); + } } // also check to update the quotas for this node @@ -1261,8 +1281,15 @@ private void traverseNode(String path) { // get the real node and update // the count and the bytes String realPath = path.substring(Quotas.quotaZookeeper.length(), path.indexOf(endString)); - updateQuotaForPath(realPath); - this.pTrie.addPath(realPath); + // A limit node directly under /zookeeper/quota yields an empty + // realPath; pTrie.addPath("") throws and aborts startup while + // rebuilding the trie from a snapshot. Skip such stray nodes. + if (!realPath.isEmpty()) { + updateQuotaForPath(realPath); + this.pTrie.addPath(realPath); + } else { + LOG.warn("Ignoring quota limit node with empty namespace: {}", path); + } } return; } diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java b/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java index 10039afa5..569f4229a 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/server/PrepRequestProcessor.java @@ -41,6 +41,8 @@ import org.apache.zookeeper.KeeperException.Code; import org.apache.zookeeper.MultiOperationRecord; import org.apache.zookeeper.Op; +import org.apache.zookeeper.Quotas; +import org.apache.zookeeper.StatsTrack; import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.ZooDefs.OpCode; import org.apache.zookeeper.common.PathUtils; @@ -384,6 +386,7 @@ protected void pRequest2Txn(int type, long zxid, Request request, Record record) validatePath(path, request.sessionId); nodeRecord = getRecordForPath(path); zks.checkACL(request.cnxn, nodeRecord.acl, ZooDefs.Perms.WRITE, request.authInfo, path, null); + validateQuotaData(path, setDataRequest.getData()); zks.checkQuota(path, nodeRecord.data, setDataRequest.getData(), OpCode.setData); int newVersion = checkAndIncVersion(nodeRecord.stat.getVersion(), setDataRequest.getVersion(), path); request.setTxn(new SetDataTxn(path, setDataRequest.getData(), newVersion)); @@ -690,6 +693,7 @@ private void pRequest2TxnCreate(int type, Request request, Record record) throws throw new KeeperException.NoChildrenForEphemeralsException(path); } int newCversion = parentRecord.stat.getCversion() + 1; + validateQuotaData(path, data); zks.checkQuota(path, null, data, OpCode.create); if (type == OpCode.createContainer) { request.setTxn(new CreateContainerTxn(path, data, listACL, newCversion)); @@ -734,6 +738,48 @@ private void validatePath(String path, long sessionId) throws BadArgumentsExcept } } + /** + * Reject a create/setData whose target is a quota {@code zookeeper_stats} or + * {@code zookeeper_limits} node when either the node is placed directly under + * {@code /zookeeper/quota} (an empty namespace) or its data is not well-formed + * StatsTrack content. These nodes live under the world-writable + * {@code /zookeeper/quota} subtree and are consumed on the transaction-apply + * path ({@link org.apache.zookeeper.server.DataTree#updateQuotaStat} and the + * path-trie registration); rejecting bad writes here stops them from ever + * being committed. The apply path is additionally hardened in + * {@link StatsTrack} and {@link DataTree}, so this is defense-in-depth rather + * than the sole guard. + * + * @param path the target node path + * @param data the proposed node data + * @throws BadArgumentsException if the path is a quota stat/limit node with + * an empty namespace or malformed {@code data} + */ + private void validateQuotaData(String path, byte[] data) throws BadArgumentsException { + if (path == null || !path.startsWith(Quotas.quotaZookeeper + "/")) { + return; + } + boolean isStatNode = path.endsWith("/" + Quotas.statNode); + boolean isLimitNode = path.endsWith("/" + Quotas.limitNode); + if (!isStatNode && !isLimitNode) { + return; + } + // A stat/limit node must sit under a non-empty namespace, i.e. + // /zookeeper/quota/<ns>/zookeeper_{stats,limits}. Placed directly under + // /zookeeper/quota it trims to an empty prefix and crashes the apply + // path (PathTrie.addPath("")). + String parent = path.substring(0, path.lastIndexOf('/')); + if (Quotas.trimQuotaPath(parent).isEmpty()) { + LOG.warn("Rejecting quota {} node with empty namespace: {}", + isLimitNode ? "limit" : "stat", path); + throw new BadArgumentsException(path); + } + if (!StatsTrack.isValidStatsData(data)) { + LOG.warn("Rejecting malformed quota data write to {}", path); + throw new BadArgumentsException(path); + } + } + private String getParentPathAndValidate(String path) throws BadArgumentsException { int lastSlash = path.lastIndexOf('/'); if (lastSlash == -1 || path.indexOf('\0') != -1 || zks.getZKDatabase().isSpecialPath(path)) { diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java index fc20ed320..6ff202455 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/server/DataTreeTest.java @@ -45,6 +45,7 @@ import org.apache.zookeeper.KeeperException.NoNodeException; import org.apache.zookeeper.KeeperException.NodeExistsException; import org.apache.zookeeper.Quotas; +import org.apache.zookeeper.StatsTrack; import org.apache.zookeeper.ZKTestCase; import org.apache.zookeeper.ZooDefs; import org.apache.zookeeper.common.PathTrie; @@ -687,4 +688,121 @@ private void testSerializeLastProcessedZxid(boolean enableForSerialize, boolean } } + /** + * Regression test for the quota-stats poisoning crash: malformed data + * committed to a {@code zookeeper_stats} node must not throw on the + * transaction-apply path (updateQuotaStat), which previously killed the + * SyncRequestProcessor critical thread and left the dataDir unbootable. + * The node must additionally self-heal into a well-formed value. + */ + @Test + @Timeout(value = 60) + public void testUpdateQuotaStatSurvivesMalformedStatsData() throws Exception { + DataTree dt = new DataTree(); + + // register /ns as a quota namespace (limit node create adds it to the trie) + dt.createNode("/ns", new byte[0], null, -1, 1, 1, 1); + dt.createNode(Quotas.quotaPath("/ns"), null, null, -1, 1, 1, 1); + dt.createNode(Quotas.limitPath("/ns"), + new StatsTrack("count=10,bytes=1000").getStatsBytes(), null, -1, 1, 1, 1); + dt.createNode(Quotas.statPath("/ns"), + new StatsTrack().getStatsBytes(), null, -1, 1, 1, 1); + + // poison: commit malformed data to the stats node (as an unauthenticated + // setData would). This write is not itself under the quota prefix, so it + // stores silently without triggering updateQuotaStat. + dt.setData(Quotas.statPath("/ns"), "x".getBytes(), 1, 2, 1); + assertEquals("x", new String(dt.getNode(Quotas.statPath("/ns")).data)); + + // detonating write: an ordinary create under the namespace re-parses the + // poisoned stats node in updateQuotaStat. Must NOT throw. + dt.createNode("/ns/child", new byte[3], null, -1, 2, 3, 1); + + // the stats node self-healed into a well-formed StatsTrack value + byte[] healed = dt.getNode(Quotas.statPath("/ns")).data; + assertTrue(StatsTrack.isValidStatsData(healed), + "stats node should be well-formed after recovery, was: " + new String(healed)); + + // a further write still succeeds (no lingering poison) + dt.setData("/ns/child", new byte[5], 1, 4, 1); + assertTrue(StatsTrack.isValidStatsData(dt.getNode(Quotas.statPath("/ns")).data)); + } + + /** + * Companion to the above for a null stats payload (jute length -1): the + * byte[] StatsTrack constructor must not NPE on the apply/replay path. + */ + @Test + @Timeout(value = 60) + public void testUpdateQuotaStatSurvivesNullStatsData() throws Exception { + DataTree dt = new DataTree(); + dt.createNode("/ns", new byte[0], null, -1, 1, 1, 1); + dt.createNode(Quotas.quotaPath("/ns"), null, null, -1, 1, 1, 1); + dt.createNode(Quotas.limitPath("/ns"), + new StatsTrack("count=10,bytes=1000").getStatsBytes(), null, -1, 1, 1, 1); + dt.createNode(Quotas.statPath("/ns"), + new StatsTrack().getStatsBytes(), null, -1, 1, 1, 1); + + // poison the stats node with a null payload + dt.setData(Quotas.statPath("/ns"), null, 1, 2, 1); + assertNull(dt.getNode(Quotas.statPath("/ns")).data); + + // detonating write must not NPE in StatsTrack(byte[]) + dt.createNode("/ns/child", new byte[3], null, -1, 2, 3, 1); + assertTrue(StatsTrack.isValidStatsData(dt.getNode(Quotas.statPath("/ns")).data)); + } + + /** + * A quota limit/stat node created directly under /zookeeper/quota trims to + * an empty namespace; previously PathTrie.addPath("") (and, on reload, + * traverseNode) threw and bricked the server. Creating them must now be a + * no-op registration rather than a crash. + */ + @Test + @Timeout(value = 60) + public void testCreateQuotaNodeWithEmptyNamespaceDoesNotCrash() throws Exception { + DataTree dt = new DataTree(); + + // limit node directly under /zookeeper/quota (empty namespace) + dt.createNode(Quotas.quotaZookeeper + "/" + Quotas.limitNode, + new StatsTrack("count=10").getStatsBytes(), null, -1, 1, 1, 1); + // stat node variant (updateQuotaForPath("")) + dt.createNode(Quotas.quotaZookeeper + "/" + Quotas.statNode, + new StatsTrack().getStatsBytes(), null, -1, 1, 2, 1); + + // no bogus empty prefix registered -> ordinary writes keep working + dt.createNode("/ok", new byte[1], null, -1, 1, 3, 1); + assertNotNull(dt.getNode("/ok")); + + // and the stray limit node can be deleted without crashing the trie + dt.deleteNode(Quotas.quotaZookeeper + "/" + Quotas.limitNode, 4); + assertNull(dt.getNode(Quotas.quotaZookeeper + "/" + Quotas.limitNode)); + } + + /** + * A {@code zookeeper_limits} node created directly under /zookeeper (a + * sibling of /zookeeper/quota, not under it) passes the create-side quota + * guard silently, but deleting it previously reached + * Quotas.trimQuotaPath("/zookeeper") — substring(16) on a 10-char string — + * and threw StringIndexOutOfBoundsException on the apply path, permanently + * bricking the dataDir. Neither create nor delete may crash. Empty data. + */ + @Test + @Timeout(value = 60) + public void testLimitNodeDirectlyUnderZookeeperDoesNotCrash() throws Exception { + DataTree dt = new DataTree(); + + String strayLimit = Quotas.procZookeeper + "/" + Quotas.limitNode; // /zookeeper/zookeeper_limits + dt.createNode(strayLimit, new byte[0], null, -1, 1, 1, 1); + assertNotNull(dt.getNode(strayLimit)); + + // the detonating delete must not throw + dt.deleteNode(strayLimit, 2); + assertNull(dt.getNode(strayLimit)); + + // server still healthy for ordinary writes + dt.createNode("/ok", new byte[1], null, -1, 1, 3, 1); + assertNotNull(dt.getNode("/ok")); + } + } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/QuotasTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/QuotasTest.java index b887e28fb..79111028f 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/QuotasTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/QuotasTest.java @@ -46,5 +46,19 @@ public void testQuotaPathPath() { public void testTrimQuotaPath() { assertEquals("/foo", Quotas.trimQuotaPath("/zookeeper/quota/foo")); assertEquals("/bar", Quotas.trimQuotaPath("/zookeeper/quota/bar")); + assertEquals("/a/b", Quotas.trimQuotaPath("/zookeeper/quota/a/b")); + } + + @Test + public void testTrimQuotaPathIsBoundsSafe() { + // Paths not under /zookeeper/quota must not throw + // StringIndexOutOfBoundsException (they reach this method via the + // transaction-apply path and would otherwise kill the server). + assertEquals("", Quotas.trimQuotaPath("/zookeeper")); + assertEquals("", Quotas.trimQuotaPath("/z")); + assertEquals("", Quotas.trimQuotaPath("")); + assertEquals("", Quotas.trimQuotaPath("/some/other/path")); + // the /zookeeper/quota root itself trims to an empty namespace + assertEquals("", Quotas.trimQuotaPath("/zookeeper/quota")); } } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/test/StatsTrackTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/test/StatsTrackTest.java index 978e629da..5b980106d 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/test/StatsTrackTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/test/StatsTrackTest.java @@ -132,4 +132,90 @@ public void testUpwardCompatibility() { Assert.assertEquals(-1, st.getByteHardLimit()); Assert.assertEquals(-1, st.getCountHardLimit()); } + + // ------------------------------------------------------------------ + // Hardening against malformed quota-stats data (ZOOKEEPER quota-poison): + // a StatsTrack parsed on the transaction-apply path must never throw, or + // it kills the SyncRequestProcessor critical thread and, since the trigger + // txn is durable, the dataDir becomes unbootable on replay. + // ------------------------------------------------------------------ + + @Test + public void testMalformedValueNoDelimiterDoesNotThrow() { + // "x" has no '=' -> previously ArrayIndexOutOfBoundsException at kv[1] + StatsTrack st = new StatsTrack("x"); + Assert.assertEquals(-1, st.getCount()); + Assert.assertEquals(-1, st.getBytes()); + } + + @Test + public void testMalformedNonNumericValueDoesNotThrow() { + // "count=abc" -> previously NumberFormatException + StatsTrack st = new StatsTrack("count=abc"); + Assert.assertEquals(-1, st.getCount()); + } + + @Test + public void testMalformedFromBytesDoesNotThrow() { + StatsTrack st = new StatsTrack("x".getBytes()); + Assert.assertEquals(-1, st.getCount()); + Assert.assertEquals(-1, st.getBytes()); + } + + @Test + public void testNullBytesDoesNotThrow() { + // null payload (jute length -1) must not NPE new String(null, ...) + StatsTrack st = new StatsTrack((byte[]) null); + Assert.assertEquals(-1, st.getCount()); + Assert.assertEquals(-1, st.getBytes()); + } + + @Test + public void testPartiallyMalformedKeepsGoodEntries() { + // good entries survive, only the bad pair is skipped + StatsTrack st = new StatsTrack("count=5,bytes=zzz"); + Assert.assertEquals(5, st.getCount()); + Assert.assertEquals(-1, st.getBytes()); + } + + @Test + public void testEmptyAndNullDoNotThrow() { + Assert.assertEquals(-1, new StatsTrack("").getCount()); + Assert.assertEquals(-1, new StatsTrack((String) null).getCount()); + } + + @Test + public void testHardLimitRoundTripStillWorks() { + // Guards the compatibility sentinel trailing '=' produced by toString(): + // parsing must recover every field, including the hard limits. + StatsTrack quota = new StatsTrack(); + quota.setCount(4); + quota.setCountHardLimit(4); + quota.setBytes(9L); + quota.setByteHardLimit(15L); + String serialized = quota.toString(); + Assert.assertEquals("count=4,bytes=9=;byteHardLimit=15;countHardLimit=4", serialized); + + StatsTrack reparsed = new StatsTrack(serialized); + Assert.assertEquals(4, reparsed.getCount()); + Assert.assertEquals(9L, reparsed.getBytes()); + Assert.assertEquals(15L, reparsed.getByteHardLimit()); + Assert.assertEquals(4, reparsed.getCountHardLimit()); + } + + @Test + public void testIsValidStatsData() { + // well-formed, including the compatibility sentinel format + Assert.assertTrue(StatsTrack.isValidStatsData(new byte[0])); + Assert.assertTrue(StatsTrack.isValidStatsData("count=5,bytes=10".getBytes())); + Assert.assertTrue(StatsTrack.isValidStatsData( + "count=4,bytes=9=;byteHardLimit=15;countHardLimit=4".getBytes())); + + // malformed -> rejected at prep time + Assert.assertFalse(StatsTrack.isValidStatsData(null)); + Assert.assertFalse(StatsTrack.isValidStatsData("x".getBytes())); + Assert.assertFalse(StatsTrack.isValidStatsData("count=abc".getBytes())); + Assert.assertFalse(StatsTrack.isValidStatsData("=5".getBytes())); + Assert.assertFalse(StatsTrack.isValidStatsData("count=5,x".getBytes())); + } }
