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