You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@jackrabbit.apache.org by th...@apache.org on 2011/10/20 11:58:29 UTC

svn commit: r1186704 - in /jackrabbit/sandbox/microkernel/src: main/java/org/apache/jackrabbit/mk/ main/java/org/apache/jackrabbit/mk/wrapper/ test/java/org/apache/jackrabbit/mk/wrapper/

Author: thomasm
Date: Thu Oct 20 09:58:29 2011
New Revision: 1186704

URL: http://svn.apache.org/viewvc?rev=1186704&view=rev
Log:
Security wrapper (to test performance problems)

Added:
    jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/wrapper/SecurityWrapper.java
    jackrabbit/sandbox/microkernel/src/test/java/org/apache/jackrabbit/mk/wrapper/
    jackrabbit/sandbox/microkernel/src/test/java/org/apache/jackrabbit/mk/wrapper/TestSecurityWrapper.java
Modified:
    jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/MicroKernelFactory.java

Modified: jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/MicroKernelFactory.java
URL: http://svn.apache.org/viewvc/jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/MicroKernelFactory.java?rev=1186704&r1=1186703&r2=1186704&view=diff
==============================================================================
--- jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/MicroKernelFactory.java (original)
+++ jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/MicroKernelFactory.java Thu Oct 20 09:58:29 2011
@@ -26,6 +26,7 @@ import org.apache.jackrabbit.mk.fs.FileU
 import org.apache.jackrabbit.mk.mem.MemoryKernelImpl;
 import org.apache.jackrabbit.mk.util.ExceptionFactory;
 import org.apache.jackrabbit.mk.wrapper.LogWrapper;
+import org.apache.jackrabbit.mk.wrapper.SecurityWrapper;
 
 /**
  * A factory to create a MicroKernel instance.
@@ -49,6 +50,8 @@ public class MicroKernelFactory {
             return MemoryKernelImpl.get(url);
         } else if (url.startsWith("log:")) {
             return LogWrapper.get(url);
+        } else if (url.startsWith("sec:")) {
+            return SecurityWrapper.get(url);
         } else if (url.startsWith("fs:")) {
             boolean clean = false;
             if (url.endsWith(";clean")) {

Added: jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/wrapper/SecurityWrapper.java
URL: http://svn.apache.org/viewvc/jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/wrapper/SecurityWrapper.java?rev=1186704&view=auto
==============================================================================
--- jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/wrapper/SecurityWrapper.java (added)
+++ jackrabbit/sandbox/microkernel/src/main/java/org/apache/jackrabbit/mk/wrapper/SecurityWrapper.java Thu Oct 20 09:58:29 2011
@@ -0,0 +1,432 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.mk.wrapper;
+
+import java.io.InputStream;
+import org.apache.jackrabbit.mk.MicroKernelFactory;
+import org.apache.jackrabbit.mk.api.MicroKernel;
+import org.apache.jackrabbit.mk.api.MicroKernelException;
+import org.apache.jackrabbit.mk.json.JsopBuilder;
+import org.apache.jackrabbit.mk.json.JsopTokenizer;
+import org.apache.jackrabbit.mk.mem.NodeImpl;
+import org.apache.jackrabbit.mk.mem.NodeMap;
+import org.apache.jackrabbit.mk.util.ExceptionFactory;
+import org.apache.jackrabbit.mk.util.PathUtils;
+import org.apache.jackrabbit.mk.util.SimpleLRUCache;
+
+/**
+ * A microkernel prototype implementation that filters nodes based on simple
+ * access rights. Each user has a password, and (optionally) a list of rights,
+ * stored as follows: /:user/x { password: "123", rights: "a" } Each node can
+ * require the user has a certain right: /data { ":right": "a" } Access rights
+ * are recursive. There is a special right "admin" which means everything is
+ * allowed, and "write" meaning a user can write.
+ * <p>
+ * This implementation is not meant for production, it is only used to find
+ * (performance and other) problems when using such an approach.
+ */
+public class SecurityWrapper implements MicroKernel {
+
+    private final MicroKernel mk;
+    private final boolean admin, write;
+    private final String[] userRights;
+    private final NodeMap map = new NodeMap();
+    private final SimpleLRUCache<String, NodeImpl> cache = SimpleLRUCache.newInstance(100);
+    private String rightsRevision;
+
+    private SecurityWrapper(MicroKernel mk, String[] rights) {
+        // TODO reduce or eliminate json parsing + building
+        // TODO make the index secure mechanism
+        this.mk = mk;
+        this.userRights = rights;
+        boolean isAdmin = false, canWrite = false;
+        for (String r : rights) {
+            if (r.equals("admin")) {
+                isAdmin = true;
+            } else if (r.equals("write")) {
+                canWrite = true;
+            }
+        }
+        admin = isAdmin;
+        write = canWrite;
+    }
+
+    public static synchronized SecurityWrapper get(String url) {
+        String userPassUrl = url.substring("sec:".length());
+        int index = userPassUrl.indexOf(':');
+        if (index < 0) {
+            throw new MicroKernelException("Expected url format: sec:user@pass:<url>");
+        }
+        String u = userPassUrl.substring(index + 1);
+        String userPass = userPassUrl.substring(0, index);
+        index = userPass.indexOf('@');
+        if (index < 0) {
+            throw new MicroKernelException("Expected url format: sec:user@pass:<url>");
+        }
+        String user = userPass.substring(0, index);
+        String pass = userPass.substring(index + 1);
+        MicroKernel mk = MicroKernelFactory.getInstance(u);
+        try {
+            String role = mk.getNodes("/:user/" + user, mk.getHeadRevision());
+            NodeMap map = new NodeMap();
+            JsopTokenizer t = new JsopTokenizer(role);
+            t.read('{');
+            NodeImpl n = NodeImpl.parse(map, t, 0);
+            String password = JsopTokenizer.decodeQuoted(n.getProperty("password"));
+            if (!pass.equals(password)) {
+                throw new MicroKernelException("Wrong password");
+            }
+            String rights = JsopTokenizer.decodeQuoted(n.getProperty("rights"));
+            return new SecurityWrapper(mk, rights.split(","));
+        } catch (MicroKernelException e) {
+            mk.dispose();
+            throw e;
+        }
+    }
+
+    public String commit(String rootPath, String jsonDiff, String revisionId, String message) {
+        checkRights(rootPath, true);
+        if (!admin) {
+            verifyDiff(jsonDiff, revisionId, rootPath, null);
+        }
+        return mk.commit(rootPath, jsonDiff, revisionId, message);
+    }
+
+    public void dispose() {
+        mk.dispose();
+    }
+
+    public String getHeadRevision() {
+        return mk.getHeadRevision();
+    }
+
+    public String getJournal(String fromRevisionId, String toRevisionId) {
+        rightsRevision = getHeadRevision();
+        String journal = mk.getJournal(fromRevisionId, toRevisionId);
+        if (admin) {
+            return journal;
+        }
+        JsopTokenizer t = new JsopTokenizer(journal);
+        t.read('[');
+        if (t.matches(']')) {
+            return journal;
+        }
+        JsopBuilder buff = new JsopBuilder();
+        buff.array();
+        String revision = fromRevisionId;
+        do {
+            t.read('{');
+            buff.object();
+            do {
+                String key = t.readString();
+                buff.key(key);
+                t.read(':');
+                if (key.equals("id")) {
+                    t.read();
+                    String value = t.getToken();
+                    revision = value;
+                    buff.value(value);
+                } else if (key.equals("changes")) {
+                    t.read();
+                    String value = t.getToken();
+                    value = filterDiff(value, revision);
+                    buff.value(value);
+                } else {
+                    String raw = t.readRawValue();
+                    System.out.println(key + ":" + raw);
+                    buff.encodedValue(raw);
+                }
+            } while (t.matches(','));
+            buff.endObject();
+            t.read('}');
+        } while (t.matches(','));
+        buff.endArray();
+        return buff.toString();
+    }
+
+    private String filterDiff(String jsonDiff, String revisionId) {
+        JsopBuilder buff = new JsopBuilder();
+        verifyDiff(jsonDiff, revisionId, null, buff);
+        return buff.toString();
+    }
+
+    private void verifyDiff(String jsonDiff, String revisionId, String rootPath, JsopBuilder diff) {
+        JsopTokenizer t = new JsopTokenizer(jsonDiff);
+        while (true) {
+            int r = t.read();
+            if (r == JsopTokenizer.END) {
+                break;
+            }
+            String path;
+            if (rootPath == null) {
+                path = t.readString();
+            } else {
+                path = PathUtils.concat(rootPath, t.readString());
+            }
+            switch (r) {
+            case '+':
+                t.read(':');
+                if (t.matches('{')) {
+                    NodeImpl n = NodeImpl.parse(map, t, 0);
+                    if (checkDiff(path, diff)) {
+                        diff.appendTag("+ ").key(path);
+                        n = filterAccess(path, n);
+                        n.append(diff, Integer.MAX_VALUE, 0, Integer.MAX_VALUE, false);
+                        diff.newline();
+                    }
+                } else {
+                    String value = t.readRawValue().trim();
+                    String nodeName = PathUtils.getParentPath(path);
+                    if (checkDiff(nodeName, diff)) {
+                        if (checkPropertyRights(path)) {
+                            diff.appendTag("+ ").key(path);
+                            diff.encodedValue(value);
+                            diff.newline();
+                        }
+                    }
+                }
+                break;
+            case '-':
+                if (checkDiff(path, diff)) {
+                    diff.appendTag("- ").value(path);
+                    diff.newline();
+                }
+                break;
+            case '^':
+                t.read(':');
+                String value;
+                if (t.matches(JsopTokenizer.NULL)) {
+                    if (checkDiff(path, diff)) {
+                        if (checkPropertyRights(path)) {
+                            diff.appendTag("^ ").key(path).value(null);
+                            diff.newline();
+                        }
+                    }
+                } else {
+                    value = t.readRawValue().trim();
+                    String nodeName = PathUtils.getParentPath(path);
+                    if (checkDiff(nodeName, diff)) {
+                        if (checkPropertyRights(path)) {
+                            diff.appendTag("^ ").key(path).encodedValue(value);
+                            diff.newline();
+                        }
+                    }
+                }
+                break;
+            case '>':
+                t.read(':');
+                checkDiff(path, diff);
+                String name = PathUtils.getName(path);
+                if (t.matches('{')) {
+                    String position = t.readString();
+                    t.read(':');
+                    String to = t.readString();
+                    String target;
+                    t.read('}');
+                    if (!PathUtils.isAbsolute(to)) {
+                        to = PathUtils.concat(rootPath, to);
+                    }
+                    if ("last".equals(position) || "first".equals(position)) {
+                        target = PathUtils.concat(to, name);
+                    } else {
+                        // before, after
+                        target = PathUtils.getParentPath(to);
+                    }
+                    if (checkDiff(target, diff)) {
+                        diff.appendTag("> ").key(path);
+                        diff.object().key(position);
+                        diff.value(to).endObject();
+                        diff.newline();
+                    }
+                } else {
+                    String to = t.readString();
+                    if (!PathUtils.isAbsolute(to)) {
+                        to = PathUtils.concat(rootPath, to);
+                    }
+                    if (checkDiff(to, diff)) {
+                        diff.appendTag("> ").key(path);
+                        diff.value(to);
+                        diff.newline();
+                    }
+                }
+                break;
+            default:
+                throw ExceptionFactory.get("token: " + (char) t.getTokenType());
+            }
+        }
+    }
+
+    private boolean checkDiff(String path, JsopBuilder target) {
+        if (checkRights(path, target == null)) {
+            return target != null;
+        } else if (target == null) {
+            throw ExceptionFactory.get("Access denied");
+        }
+        return false;
+    }
+
+    public long getLength(String blobId) {
+        return mk.getLength(blobId);
+    }
+
+    public String getNodes(String path, String revisionId) {
+        return getNodes(path, revisionId, 1, 0, -1);
+    }
+
+    public String getNodes(String path, String revisionId, int depth, long offset, int count) {
+        rightsRevision = getHeadRevision();
+        if (!checkRights(path, false)) {
+            throw ExceptionFactory.get("Node not found: " + path);
+        }
+        String json = mk.getNodes(path, revisionId, depth, offset, count);
+        if (admin) {
+            return json;
+        }
+        JsopTokenizer t = new JsopTokenizer(json);
+        t.read('{');
+        NodeImpl n = NodeImpl.parse(map, t, 0);
+        n = filterAccess(path, n);
+        JsopBuilder buff = new JsopBuilder();
+        if (n == null) {
+            throw ExceptionFactory.get("Node not found: " + path);
+        } else {
+            // TODO childNodeCount properties might be wrong
+            // when count and offset are used
+            n.append(buff, Integer.MAX_VALUE, 0, Integer.MAX_VALUE, true);
+        }
+        return buff.toString();
+    }
+
+    public String getRevisions(long since, int maxEntries) {
+        return mk.getRevisions(since, maxEntries);
+    }
+
+    public boolean nodeExists(String path, String revisionId) {
+        rightsRevision = getHeadRevision();
+        if (!checkRights(path, false)) {
+            return false;
+        }
+        return mk.nodeExists(path, revisionId);
+    }
+
+    public int read(String blobId, long pos, byte[] buff, int off, int length) {
+        return mk.read(blobId, pos, buff, off, length);
+    }
+
+    public String waitForCommit(String oldHeadRevision, long maxWaitMillis) throws InterruptedException {
+        return mk.waitForCommit(oldHeadRevision, maxWaitMillis);
+    }
+
+    public String write(InputStream in) {
+        rightsRevision = getHeadRevision();
+        checkRights(null, true);
+        return mk.write(in);
+    }
+
+    private NodeImpl filterAccess(String path, NodeImpl n) {
+        if (!checkRights(path, false)) {
+            return null;
+        }
+        if (!admin && n.hasProperty(":rights")) {
+            n = n.cloneAndSetProperty(":rights", null, 0);
+        }
+        for (long pos = 0;; pos++) {
+            String childName = n.getChildNodeName(pos);
+            if (childName == null) {
+                break;
+            }
+            NodeImpl c = n.getNode(childName);
+            NodeImpl c2 = filterAccess(PathUtils.concat(path, childName), c);
+            if (c2 != c) {
+                if (c2 == null) {
+                    n = n.cloneAndRemoveChildNode(childName, 0);
+                } else {
+                    n = n.setChild(childName, c2, 0);
+                }
+            }
+        }
+        return n;
+    }
+
+    private boolean checkPropertyRights(String path) {
+        return !PathUtils.getName(path).equals(":rights");
+    }
+
+    private boolean checkRights(String path, boolean write) {
+        if (admin) {
+            return true;
+        }
+        if (write && !this.write) {
+            return false;
+        }
+        if (path == null) {
+            return true;
+        }
+        boolean access = false;
+        while (true) {
+            String key = path + "@" + rightsRevision;
+            NodeImpl n = cache.get(key);
+            if (n == null) {
+                if (mk.nodeExists(path, rightsRevision)) {
+                    String json = mk.getNodes(path, rightsRevision, 0, 0, 0);
+                    JsopTokenizer t = new JsopTokenizer(json);
+                    t.read('{');
+                    n = NodeImpl.parse(map, t, 0);
+                } else {
+                    n = new NodeImpl(map, 0);
+                }
+                cache.put(key, n);
+            }
+            Boolean b = hasRights(n);
+            if (b != null) {
+                if (b) {
+                    access = true;
+                } else {
+                    return false;
+                }
+            }
+            // check parent
+            if (PathUtils.denotesRoot(path)) {
+                break;
+            }
+            path = PathUtils.getParentPath(path);
+        }
+        return access;
+    }
+
+    private Boolean hasRights(NodeImpl n) {
+        String rights = n.getProperty(":rights");
+        if (rights == null) {
+            return null;
+        }
+        rights = JsopTokenizer.decodeQuoted(rights);
+        for (String r : rights.split(",")) {
+            boolean got = false;
+            for (String u : userRights) {
+                if (u.equals(r)) {
+                    got = true;
+                    break;
+                }
+            }
+            if (!got) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+}

Added: jackrabbit/sandbox/microkernel/src/test/java/org/apache/jackrabbit/mk/wrapper/TestSecurityWrapper.java
URL: http://svn.apache.org/viewvc/jackrabbit/sandbox/microkernel/src/test/java/org/apache/jackrabbit/mk/wrapper/TestSecurityWrapper.java?rev=1186704&view=auto
==============================================================================
--- jackrabbit/sandbox/microkernel/src/test/java/org/apache/jackrabbit/mk/wrapper/TestSecurityWrapper.java (added)
+++ jackrabbit/sandbox/microkernel/src/test/java/org/apache/jackrabbit/mk/wrapper/TestSecurityWrapper.java Thu Oct 20 09:58:29 2011
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.mk.wrapper;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import junit.framework.Assert;
+import org.apache.jackrabbit.mk.MicroKernelFactory;
+import org.apache.jackrabbit.mk.MultiMkTestBase;
+import org.apache.jackrabbit.mk.api.MicroKernel;
+import org.apache.jackrabbit.mk.api.MicroKernelException;
+import org.apache.jackrabbit.mk.json.JsopTokenizer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+/**
+ * Test the security wrapper.
+ */
+@RunWith(Parameterized.class)
+public class TestSecurityWrapper extends MultiMkTestBase {
+
+    private String head;
+    private MicroKernel mkAdmin;
+    private MicroKernel mkGuest;
+
+    public TestSecurityWrapper(String url) {
+        super(url);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        head = mk.getHeadRevision();
+        head = mk.commit("/", "+ \":user\": { \":rights\":\"admin\" }", head, "");
+        head = mk.commit("/", "+ \":user/sa\": {\"password\": \"abc\", \"rights\":\"admin\" }", head, "");
+        head = mk.commit("/", "+ \":user/guest\": {\"password\": \"guest\", \"rights\":\"read\" }", head, "");
+        mkAdmin = MicroKernelFactory.getInstance("sec:sa@abc:" + url);
+        mkGuest = MicroKernelFactory.getInstance("sec:guest@guest:" + url);
+    }
+
+    @After
+    public void tearDown() throws InterruptedException {
+        try {
+            if (mkAdmin != null) {
+                mkAdmin.dispose();
+            }
+            if (mkGuest != null) {
+                mkGuest.dispose();
+            }
+            super.tearDown();
+        } catch (Throwable e) {
+            e.printStackTrace();
+        }
+    }
+
+    @Test
+    public void wrongPassword() {
+        try {
+            MicroKernelFactory.getInstance("sec:sa@xyz:" + url);
+            fail();
+        } catch (Throwable e) {
+            // expected (wrong password)
+        }
+    }
+
+    @Test
+    public void commit() {
+        head = mkAdmin.commit("/", "+ \"test\": { \"data\": \"Hello\" }", head, null);
+        head = mkAdmin.commit("/", "- \"test\"", head, null);
+        try {
+            head = mkGuest.commit("/", "+ \"test\": { \"data\": \"Hello\" }", head, null);
+            fail();
+        } catch (MicroKernelException e) {
+            // expected
+        }
+    }
+
+    @Test
+    public void getJournal() {
+        if (url.startsWith("fs:")) {
+            return;
+        }
+        String fromRevision = mkAdmin.getHeadRevision();
+        String toRevision = mkAdmin.commit("/", "+ \"test\": { \"data\": \"Hello\" }", head, "");
+        toRevision = mkAdmin.commit("/", "^ \"test/data\": \"Hallo\"", toRevision, "");
+        toRevision = mkAdmin.commit("/", "^ \"test/data\": null", toRevision, "");
+        String j2 = mkGuest.getJournal(fromRevision, toRevision);
+        assertEquals("", filterJournal(j2));
+        toRevision = mkAdmin.commit("/", "^ \":rights\": \"read\"", fromRevision, "");
+        String j3 = mkGuest.getJournal(fromRevision, toRevision);
+        assertEquals(
+                "+ \"/test\":{\"data\":\"Hello\"}\n" +
+                "^ \"/test/data\":\"Hallo\"\n" +
+                "^ \"/test/data\":null\n",
+                filterJournal(j3));
+        String journal = mkAdmin.getJournal(fromRevision, toRevision);
+        assertEquals(
+                "+ \"/test\":{\"data\":\"Hello\"}\n" +
+                "^ \"/test/data\":\"Hallo\"\n" +
+                "^ \"/test/data\":null\n" +
+                "+ \"/:rights\":\"read\"",
+                filterJournal(journal));
+    }
+
+    @Test
+    public void getNodes() {
+        if (url.startsWith("fs:")) {
+            return;
+        }
+        head = mk.getHeadRevision();
+        assertTrue(mkAdmin.nodeExists("/:user", head));
+        assertFalse(mkGuest.nodeExists("/:user", head));
+        head = mkAdmin.commit("/", "^ \":rights\": \"read\"", head, "");
+        head = mkAdmin.commit("/", "+ \"test\": { \"data\": \"Hello\" }", head, "");
+        assertTrue(mkAdmin.nodeExists("/", head));
+        assertTrue(mkGuest.nodeExists("/", head));
+        assertEquals("{\":rights\":\"read\",\":childNodeCount\":2,\":user\":{\":rights\":\"admin\",\":childNodeCount\":2,\"sa\":{},\"guest\":{}},\"test\":{\"data\":\"Hello\",\":childNodeCount\":0}}", mkAdmin.getNodes("/", head));
+        assertEquals("{\":childNodeCount\":1,\"test\":{\"data\":\"Hello\",\":childNodeCount\":0}}", mkGuest.getNodes("/", head));
+    }
+
+    private String filterJournal(String journal) {
+        JsopTokenizer t = new JsopTokenizer(journal);
+        StringBuilder buff = new StringBuilder();
+        t.read('[');
+        boolean isNew = false;
+        do {
+            t.read('{');
+            Assert.assertEquals("id", t.readString());
+            t.read(':');
+            t.readString();
+            t.read(',');
+            Assert.assertEquals("ts", t.readString());
+            t.read(':');
+            t.read(JsopTokenizer.NUMBER);
+            t.read(',');
+            Assert.assertEquals("msg", t.readString());
+            t.read(':');
+            t.read();
+            t.read(',');
+            Assert.assertEquals("changes", t.readString());
+            t.read(':');
+            String changes = t.readString().trim();
+            if (isNew) {
+                if (buff.length() > 0) {
+                    buff.append('\n');
+                }
+                buff.append(changes);
+            }
+            // the first revision isn't new, all others are
+            isNew = true;
+            t.read('}');
+        } while (t.matches(','));
+        return buff.toString();
+    }
+
+}