Support format=TEXT for /+refs

List refs in the same format in an /info/refs request for the git
protocol, plus some minor sanitization to deal with XSS. Support scoping
of refs by adding additional path components (e.g. /heads) after /+refs.
This means allowing refs views with paths, but note that this only
applies to the TEXT (and soon JSON) format, not HTML.

Change-Id: I7c7074544af366f38791d5a90d0a024d2555c92e
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
index 42079ee..67040fe 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -205,6 +205,7 @@
         case DIFF:
           this.path = maybeTrimLeadingAndTrailingSlash(checkNotNull(path));
           return this;
+        case REFS:
         case LOG:
           this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null;
           return this;
@@ -348,7 +349,7 @@
     return new Builder(Type.LOG);
   }
 
-  private static String maybeTrimLeadingAndTrailingSlash(String str) {
+  static String maybeTrimLeadingAndTrailingSlash(String str) {
     if (str.startsWith("/")) {
       str = str.substring(1);
     }
@@ -522,6 +523,8 @@
    *     auto-diving into one-entry subtrees.
    */
   public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) {
+    checkArgument(type != Type.REFS || Strings.isNullOrEmpty(path),
+        "breadcrumbs for REFS view with path not supported");
     checkArgument(hasSingleTree == null || type == Type.PATH,
         "hasSingleTree must be null for %s view", type);
     String path = this.path;
@@ -551,7 +554,8 @@
       breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this)));
     }
     if (path != null) {
-      if (type != Type.LOG) { // The "." breadcrumb would be no different for LOG.
+      if (type != Type.LOG && type != Type.REFS) {
+        // The "." breadcrumb would be no different for LOG or REFS.
         breadcrumbs.add(breadcrumb(".", copyWithPath().setTreePath("")));
       }
       StringBuilder cur = new StringBuilder();
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
index a046ff1..84cfdb2 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
@@ -15,7 +15,6 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-
 import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
index b75965c..f3dcc43 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
@@ -15,7 +15,6 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
index bb51f04..fd5e793 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RefServlet.java
@@ -15,6 +15,7 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableMap;
@@ -24,13 +25,16 @@
 import com.google.common.util.concurrent.UncheckedExecutionException;
 
 import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefComparator;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.RefAdvertiser;
 
 import java.io.IOException;
+import java.io.PrintWriter;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -51,25 +55,41 @@
   }
 
   @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse res)
+  protected void doGetHtml(HttpServletRequest req, HttpServletResponse res)
       throws IOException {
+    if (!ViewFilter.getView(req).getTreePath().isEmpty()) {
+      res.setStatus(SC_NOT_FOUND);
+      return;
+    }
     RevWalk walk = new RevWalk(ServletUtils.getRepository(req));
     List<Map<String, Object>> tags;
     try {
-      tags = getTags(req, timeCache, walk, 0);
+      tags = getTagsSoyData(req, timeCache, walk, 0);
     } finally {
       walk.release();
     }
     renderHtml(req, res, "gitiles.refsDetail",
-        ImmutableMap.of("branches", getBranches(req, 0), "tags", tags));
+        ImmutableMap.of("branches", getBranchesSoyData(req, 0), "tags", tags));
   }
 
-  static List<Map<String, Object>> getBranches(HttpServletRequest req, int limit)
+  @Override
+  protected void doGetText(HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    Map<String, Ref> refs = getRefs(ServletUtils.getRepository(req).getRefDatabase(),
+        view.getTreePath());
+    TextRefAdvertiser adv = new TextRefAdvertiser(startRenderText(req, res));
+    adv.setDerefTags(true);
+    adv.send(refs);
+    adv.end();
+  }
+
+  static List<Map<String, Object>> getBranchesSoyData(HttpServletRequest req, int limit)
       throws IOException {
     RefDatabase refdb = ServletUtils.getRepository(req).getRefDatabase();
     Ref head = refdb.getRef(Constants.HEAD);
     Ref headLeaf = head != null && head.isSymbolic() ? head.getLeaf() : null;
-    return getRefs(
+    return getRefsSoyData(
         refdb,
         ViewFilter.getView(req),
         Constants.R_HEADS,
@@ -97,9 +117,9 @@
     }.compound(RefComparator.INSTANCE);
   }
 
-  static List<Map<String, Object>> getTags(HttpServletRequest req,
+  static List<Map<String, Object>> getTagsSoyData(HttpServletRequest req,
       TimeCache timeCache, RevWalk walk, int limit) throws IOException {
-    return getRefs(
+    return getRefsSoyData(
         ServletUtils.getRepository(req).getRefDatabase(),
         ViewFilter.getView(req),
         Constants.R_TAGS,
@@ -121,7 +141,7 @@
     }).reverse().compound(RefComparator.INSTANCE);
   }
 
-  private static List<Map<String, Object>> getRefs(
+  private static List<Map<String, Object>> getRefsSoyData(
       RefDatabase refdb,
       GitilesView view,
       String prefix,
@@ -146,4 +166,46 @@
     }
     return result;
   }
+
+  private static String sanitizeRefForText(String refName) {
+    return refName.replace("&", "&amp;")
+        .replace("<", "&lt;")
+        .replace(">", "&gt;");
+  }
+
+  private static Map<String, Ref> getRefs(RefDatabase refdb, String path) throws IOException {
+    path = GitilesView.maybeTrimLeadingAndTrailingSlash(path);
+    if (path.isEmpty()) {
+      return refdb.getRefs(RefDatabase.ALL);
+    }
+    path = Constants.R_REFS + path;
+    Ref singleRef = refdb.getRef(path);
+    if (singleRef != null) {
+      return ImmutableMap.of(singleRef.getName(), singleRef);
+    }
+    return refdb.getRefs(path + '/');
+  }
+
+  private static class TextRefAdvertiser extends RefAdvertiser {
+    private final PrintWriter writer;
+
+    private TextRefAdvertiser(PrintWriter writer) {
+      this.writer = writer;
+    }
+
+    @Override
+    public void advertiseId(AnyObjectId id, String refName) throws IOException {
+      super.advertiseId(id, sanitizeRefForText(refName));
+    }
+
+    @Override
+    protected void writeOne(CharSequence line) throws IOException {
+      writer.print(line);
+    }
+
+    @Override
+    public void end() throws IOException {
+      writer.close();
+    }
+  }
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
index 9672815..5a92dea 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -67,7 +67,7 @@
     List<Map<String, Object>> tags;
     Map<String, Object> data;
     try {
-      tags = RefServlet.getTags(req, timeCache, walk, REF_LIMIT);
+      tags = RefServlet.getTagsSoyData(req, timeCache, walk, REF_LIMIT);
       ObjectId headId = repo.resolve(Constants.HEAD);
       if (headId != null) {
         RevObject head = walk.parseAny(headId);
@@ -88,7 +88,7 @@
     if (!data.containsKey("entries")) {
       data.put("entries", ImmutableList.of());
     }
-    List<Map<String, Object>> branches = RefServlet.getBranches(req, REF_LIMIT);
+    List<Map<String, Object>> branches = RefServlet.getBranchesSoyData(req, REF_LIMIT);
 
     data.put("cloneUrl", desc.cloneUrl);
     data.put("mirroredFromUrl", Strings.nullToEmpty(desc.mirroredFromUrl));
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
index 4f629be..c24b4f0 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -102,8 +102,8 @@
     // Non-path cases.
     if (repoName.isEmpty()) {
       return GitilesView.hostIndex();
-    } else if (command.equals(CMD_REFS) && path.isEmpty()) {
-      return GitilesView.refs().setRepositoryName(repoName);
+    } else if (command.equals(CMD_REFS)) {
+      return GitilesView.refs().setRepositoryName(repoName).setTreePath(path);
     } else if (command.equals(CMD_LOG) && (path.isEmpty() || path.equals("/"))) {
       return GitilesView.log().setRepositoryName(repoName);
     } else if (command.isEmpty()) {
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/RefServletTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/RefServletTest.java
new file mode 100644
index 0000000..9e8bac4
--- /dev/null
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/RefServletTest.java
@@ -0,0 +1,177 @@
+// Copyright 2012 Google Inc. All Rights Reserved.
+//
+// Licensed 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 com.google.gitiles;
+
+import junit.framework.TestCase;
+
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.storage.dfs.DfsRepository;
+import org.eclipse.jgit.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.storage.dfs.InMemoryRepository;
+
+import java.io.IOException;
+
+/** Tests for {@link Linkifier}. */
+public class RefServletTest extends TestCase {
+  private TestRepository<DfsRepository> repo;;
+  private GitilesServlet servlet;
+
+  @Override
+  protected void setUp() throws Exception {
+    DfsRepository r = new InMemoryRepository(new DfsRepositoryDescription("test"));
+    repo = new TestRepository<DfsRepository>(r);
+
+    RevCommit commit = repo.branch("refs/heads/master").commit().create();
+    repo.update("refs/heads/branch", commit);
+    repo.update("refs/tags/ctag", commit);
+    RevTag tag = repo.tag("atag", commit);
+    repo.update("refs/tags/atag", tag);
+    r.updateRef("HEAD").link("refs/heads/master");
+
+    servlet = TestGitilesServlet.create(repo);
+  }
+
+  private String id(String refName) throws IOException {
+    return ObjectId.toString(repo.getRepository().getRef(refName).getObjectId());
+  }
+
+  private String peeled(String refName) throws IOException {
+    return ObjectId.toString(repo.getRepository().peel(
+          repo.getRepository().getRef(refName)).getPeeledObjectId());
+  }
+
+  public void testEvilRefName() throws Exception {
+    String evilRefName = "refs/evil/<script>window.close();</script>/&foo";
+    assertTrue(Repository.isValidRefName(evilRefName));
+    repo.branch(evilRefName).commit().create();
+
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs/evil");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(
+        id(evilRefName) + " refs/evil/&lt;script&gt;window.close();&lt;/script&gt;/&amp;foo\n",
+        res.getActualBodyString());
+  }
+
+  public void testGetRefsTextAll() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(200, res.getStatus());
+    assertEquals(
+        id("HEAD") + " HEAD\n"
+        + id("refs/heads/branch") + " refs/heads/branch\n"
+        + id("refs/heads/master") + " refs/heads/master\n"
+        + id("refs/tags/atag") + " refs/tags/atag\n"
+        + peeled("refs/tags/atag") + " refs/tags/atag^{}\n"
+        + id("refs/tags/ctag") + " refs/tags/ctag\n",
+        res.getActualBodyString());
+  }
+
+  public void testGetRefsTextAllTrailingSlash() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(200, res.getStatus());
+    assertEquals(
+        id("HEAD") + " HEAD\n"
+        + id("refs/heads/branch") + " refs/heads/branch\n"
+        + id("refs/heads/master") + " refs/heads/master\n"
+        + id("refs/tags/atag") + " refs/tags/atag\n"
+        + peeled("refs/tags/atag") + " refs/tags/atag^{}\n"
+        + id("refs/tags/ctag") + " refs/tags/ctag\n",
+        res.getActualBodyString());
+  }
+
+  public void testGetRefsHeadsText() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs/heads");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(200, res.getStatus());
+    assertEquals(
+        id("refs/heads/branch") + " refs/heads/branch\n"
+        + id("refs/heads/master") + " refs/heads/master\n",
+        res.getActualBodyString());
+  }
+
+  public void testGetRefsHeadsTextTrailingSlash() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs/heads/");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(200, res.getStatus());
+    assertEquals(
+        id("refs/heads/branch") + " refs/heads/branch\n"
+        + id("refs/heads/master") + " refs/heads/master\n",
+        res.getActualBodyString());
+  }
+
+  public void testNoHeadText() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs/HEAD");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(200, res.getStatus());
+    // /+refs/foo means refs/foo(/*), so this is empty.
+    assertEquals("", res.getActualBodyString());
+  }
+
+  public void testSingleHeadText() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs/heads/master");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(200, res.getStatus());
+    assertEquals(
+        id("refs/heads/master") + " refs/heads/master\n",
+        res.getActualBodyString());
+  }
+
+  public void testSinglePeeledTagText() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/test/+refs/tags/atag");
+    req.setQueryString("format=TEXT");
+    FakeHttpServletResponse res = new FakeHttpServletResponse();
+    servlet.service(req, res);
+
+    assertEquals(200, res.getStatus());
+    assertEquals(
+        id("refs/tags/atag") + " refs/tags/atag\n"
+        + peeled("refs/tags/atag") + " refs/tags/atag^{}\n",
+        res.getActualBodyString());
+  }
+}
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
index 2c3ae2a..1088692 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -97,12 +97,42 @@
   }
 
   public void testRefs() throws Exception {
-    GitilesView view = getView("/repo/+refs");
+    GitilesView view;
+
+    view = getView("/repo/+refs");
     assertEquals(Type.REFS, view.getType());
     assertEquals("repo", view.getRepositoryName());
     assertEquals(Revision.NULL, view.getRevision());
     assertEquals(Revision.NULL, view.getOldRevision());
-    assertNull(view.getTreePath());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+refs/");
+    assertEquals(Type.REFS, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("", view.getTreePath());
+
+    view = getView("/repo/+refs/heads");
+    assertEquals(Type.REFS, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("heads", view.getTreePath());
+
+    view = getView("/repo/+refs/heads/");
+    assertEquals(Type.REFS, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("heads", view.getTreePath());
+
+    view = getView("/repo/+refs/heads/master");
+    assertEquals(Type.REFS, view.getType());
+    assertEquals("repo", view.getRepositoryName());
+    assertEquals(Revision.NULL, view.getRevision());
+    assertEquals(Revision.NULL, view.getOldRevision());
+    assertEquals("heads/master", view.getTreePath());
   }
 
   public void testBranches() throws Exception {