Merge branch 'master' of https://code.google.com/p/plexi

Conflicts:
	src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
diff --git a/src/com/google/enterprise/adaptor/Config.java b/src/com/google/enterprise/adaptor/Config.java
index 2fc1f9e..30c6625 100644
--- a/src/com/google/enterprise/adaptor/Config.java
+++ b/src/com/google/enterprise/adaptor/Config.java
@@ -36,6 +36,10 @@
  *     <td><b>name</b></td><td><b>meaning</b></td>
  * <tr><td> </td><td>adaptor.autoUnzip </td><td> expand zip files and send
  *     each file inside   separatly.  Defaults to false
+ * <tr><td> </td><td>adaptor.sendDocControlsHeader </td><td>use 
+ *      X-Gsa-Doc-Controls HTTP header with namespaced ACLs.
+ *      Otherwise ACLs are sent without namespace and as metadata.
+ *      Defaults to false
  * <tr><td> </td><td>adaptor.fullListingSchedule </td><td> when to invoke 
  *     {@link Adaptor#getDocIds Adaptor.getDocIds}, in cron format (minute,
  *     hour,  day of month, month, day of week).  Defaults to 0 3 * * *
@@ -207,6 +211,7 @@
     addKey("transform.maxDocumentBytes", "1048576");
     addKey("transform.required", "false");
     addKey("journal.reducedMem", "true");
+    addKey("adaptor.sendDocControlsHeader", "false");
   }
 
   public Set<String> getAllKeys() {
@@ -394,6 +399,10 @@
     return Boolean.parseBoolean(getValue("server.useCompression"));
   }
 
+  public boolean sendDocControlsHeader() {
+    return Boolean.parseBoolean(getValue("adaptor.sendDocControlsHeader"));
+  }
+
   /**
    * Optional (default false): Adds no-recrawl bit with sent records in feed
    * file. If connector handles updates and deletes then GSA does not have to
diff --git a/src/com/google/enterprise/adaptor/DocumentHandler.java b/src/com/google/enterprise/adaptor/DocumentHandler.java
index c7ffb56..265165e 100644
--- a/src/com/google/enterprise/adaptor/DocumentHandler.java
+++ b/src/com/google/enterprise/adaptor/DocumentHandler.java
@@ -16,10 +16,15 @@
 
 import static java.util.Map.Entry;
 
+import com.google.common.annotations.VisibleForTesting;
+
 import com.sun.net.httpserver.HttpExchange;
 import com.sun.net.httpserver.HttpHandler;
 import com.sun.net.httpserver.HttpsExchange;
 
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -61,6 +66,7 @@
   private final int transformMaxBytes;
   private final boolean transformRequired;
   private final boolean useCompression;
+  private final boolean sendDocControls;
 
   /**
    * {@code authnHandler} and {@code transform} may be {@code null}.
@@ -72,7 +78,8 @@
                          SessionManager<HttpExchange> sessionManager,
                          TransformPipeline transform, int transformMaxBytes,
                          boolean transformRequired, boolean useCompression,
-                         Watchdog watchdog, AsyncPusher pusher) {
+                         Watchdog watchdog, AsyncPusher pusher,
+                         boolean sendDocControls) {
     if (docIdDecoder == null || docIdEncoder == null || journal == null
         || adaptor == null || sessionManager == null || watchdog == null
         || pusher == null) {
@@ -90,6 +97,7 @@
     this.useCompression = useCompression;
     this.watchdog = watchdog;
     this.pusher = pusher;
+    this.sendDocControls = sendDocControls;
 
     initFullAccess(gsaHostname, fullAccessHosts);
   }
@@ -292,7 +300,8 @@
     return (sb.length() == 0) ? "" : sb.substring(0, sb.length() - 1);
   }
 
-  static String formAclHeader(Acl acl, DocIdEncoder docIdEncoder) {
+  @VisibleForTesting
+  static String formUnqualifiedAclHeader(Acl acl, DocIdEncoder docIdEncoder) {
     if (acl == null) {
       return "";
     }
@@ -333,6 +342,62 @@
     return sb.substring(0, sb.length() - 1);
   }
 
+  @VisibleForTesting
+  static String formNamespacedAclHeader(Acl acl, DocIdEncoder enc) {
+    if (null == acl) {
+      return "";
+    }
+    if (Acl.EMPTY.equals(acl)) {
+      acl = Acl.FAKE_EMPTY;
+    }
+    Map<String, Object> gsaAcl = new TreeMap<String, Object>();
+    List<Map<String, String>> gsaAclEntries = makeGsaAclEntries(acl);    
+    if (!gsaAclEntries.isEmpty()) {
+      gsaAcl.put("entries", gsaAclEntries);
+    }
+    if (null != acl.getInheritFrom()) {
+      URI from = enc.encodeDocId(acl.getInheritFrom());
+      gsaAcl.put("inherit_from", "" + from);
+    }
+    if (acl.getInheritanceType() != Acl.InheritanceType.LEAF_NODE) {
+      String type = "" + acl.getInheritanceType();
+      gsaAcl.put("inheritance_type", "" + type);
+    }
+    return JSONObject.toJSONString(gsaAcl);
+  }
+
+  private static List<Map<String, String>> makeGsaAclEntries(Acl acl) {
+    List<Map<String, String>> princ = new ArrayList<Map<String, String>>();
+    for (Principal p : acl.getPermitGroups()) {
+      princ.add(makeGsaAclEntry("permit", acl, p));
+    }
+    for (Principal p : acl.getDenyGroups()) {
+      princ.add(makeGsaAclEntry("deny", acl, p));
+    }
+    for (Principal p : acl.getPermitUsers()) {
+      princ.add(makeGsaAclEntry("permit", acl, p));
+    }
+    for (Principal p : acl.getDenyUsers()) {
+      princ.add(makeGsaAclEntry("deny", acl, p));
+    }
+    return princ;
+  }
+
+  private static Map<String, String> makeGsaAclEntry(String access,
+      Acl acl, Principal p) {
+    Map<String, String> gsaEntry = new TreeMap<String, String>();
+    gsaEntry.put("access", access);
+    gsaEntry.put("scope", p.isUser() ? "user" : "group");
+    gsaEntry.put("name", p.getName());
+    if (!Principal.DEFAULT_NAMESPACE.equals(p.getNamespace())) {
+      gsaEntry.put("namespace", p.getNamespace());
+    }
+    if (!acl.isEverythingCaseSensitive()) {
+      gsaEntry.put("case_sensitivity_type", "everything_case_insensitive");
+    }
+    return gsaEntry;
+  }
+
   /**
    * Format the GSA-specific anchor header value for extra crawl-time anchors.
    */
@@ -714,20 +779,39 @@
 
     private void startSending(boolean hasContent) throws IOException {
       if (requestIsFromFullyTrustedClient(ex)) {
-        if (displayUrl != null || crawlOnce || lock) {
-          // Emulate these crawl-time values by sending them in feeds since they
-          // aren't supported at crawl-time on GSA 7.0.
-          pusher.asyncPushItem(new DocIdPusher.Record.Builder(docId)
-              .setResultLink(displayUrl).setCrawlOnce(crawlOnce).setLock(lock)
-              .build());
-        }
         // Always specify metadata and ACLs, even when empty, to replace
         // previous values.
         ex.getResponseHeaders().add("X-Gsa-External-Metadata",
-                                    formMetadataHeader(metadata));
-        acl = checkAndWorkaroundGsa70Acl(acl);
-        ex.getResponseHeaders().add("X-Gsa-External-Metadata",
-                                    formAclHeader(acl, docIdEncoder));
+             formMetadataHeader(metadata));
+        if (sendDocControls) {
+          ex.getResponseHeaders().add("X-Gsa-Doc-Controls", "acl="
+              + percentEncode(formNamespacedAclHeader(acl, docIdEncoder)));
+          if (null != displayUrl) {
+            String link = "display_url=" + percentEncode("" + displayUrl);
+            ex.getResponseHeaders().add("X-Gsa-Doc-Controls", link);
+          }
+          /*
+            TODO(ejona): enable once sending crawl-once at crawl time is possible
+            ex.getResponseHeaders().add("X-Gsa-Doc-Controls",
+                "crawl-once=" + crawlOnce);
+          */
+          /*
+            TODO(ejona): enable once sending lock at crawl time is possible
+            ex.getResponseHeaders().add("X-Gsa-Doc-Controls", "lock=" + lock);
+          */
+        } else {
+          acl = checkAndWorkaroundGsa70Acl(acl);
+          ex.getResponseHeaders().add("X-Gsa-External-Metadata",
+              formUnqualifiedAclHeader(acl, docIdEncoder));
+          if (displayUrl != null || crawlOnce || lock) {
+            // Emulate these crawl-time values by sending them in feeds
+            // since they aren't supported at crawl-time on GSA 7.0.
+            pusher.asyncPushItem(new DocIdPusher.Record.Builder(docId)
+                .setResultLink(displayUrl).setCrawlOnce(crawlOnce).setLock(lock)
+                .build());
+            // TODO: figure out how to notice that a true went false
+          }
+        }
         if (!anchorUris.isEmpty()) {
           ex.getResponseHeaders().add("X-Gsa-External-Anchor",
               formAnchorHeader(anchorUris, anchorTexts));
diff --git a/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java b/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
index 9ae49d0..7ef85c4 100644
--- a/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
+++ b/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
@@ -268,17 +268,20 @@
         5 /* max latency */, TimeUnit.MINUTES,
         2 * config.getFeedMaxUrls() /* queue size */);
     backgroundExecutor.execute(waiter.runnable(asyncDocIdSender.worker()));
-    addFilters(scope.createContext(config.getServerBaseUri().getPath()
-        + config.getServerDocIdPath(),
-        new DocumentHandler(docIdCodec, docIdCodec, journal, adaptor,
-                            config.getGsaHostname(),
-                            config.getServerFullAccessHosts(),
-                            authnHandler, sessionManager,
-                            createTransformPipeline(),
-                            config.getTransformMaxDocumentBytes(),
-                            config.isTransformRequired(),
-                            config.isServerToUseCompression(), watchdog,
-                            asyncDocIdSender)));
+    DocumentHandler docHandler = new DocumentHandler(
+        docIdCodec, docIdCodec, journal, adaptor,
+        config.getGsaHostname(),
+        config.getServerFullAccessHosts(),
+        authnHandler, sessionManager,
+        createTransformPipeline(),
+        config.getTransformMaxDocumentBytes(),
+        config.isTransformRequired(),
+        config.isServerToUseCompression(), watchdog,
+        asyncDocIdSender, 
+        config.sendDocControlsHeader());
+    String handlerPath = config.getServerBaseUri().getPath()
+        + config.getServerDocIdPath();
+    addFilters(scope.createContext(handlerPath, docHandler));
 
     // Start communicating with other services. As a general rule, by this time
     // we want all services we provide to be up and running. However, note that
diff --git a/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java b/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java
index 6fd109e..feab60e 100644
--- a/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java
+++ b/src/com/google/enterprise/adaptor/examples/AdaptorWithCrawlTimeMetadataTemplate.java
@@ -16,6 +16,9 @@
 
 import com.google.enterprise.adaptor.AbstractAdaptor;
 import com.google.enterprise.adaptor.Acl;
+import com.google.enterprise.adaptor.AuthnIdentity;
+import com.google.enterprise.adaptor.AuthzStatus;
+import com.google.enterprise.adaptor.Config;
 import com.google.enterprise.adaptor.DocId;
 import com.google.enterprise.adaptor.DocIdPusher;
 import com.google.enterprise.adaptor.GroupPrincipal;
@@ -24,6 +27,7 @@
 import com.google.enterprise.adaptor.UserPrincipal;
 
 import java.io.*;
+import java.net.*;
 import java.nio.charset.Charset;
 import java.util.*;
 import java.util.logging.Logger;
@@ -32,7 +36,9 @@
  * Demonstrates what code is necessary for putting restricted
  * content onto a GSA.  The key operations are:
  * <ol><li> providing document ids
- *   <li> providing document bytes and ACLs given a document id</ol>
+ *   <li> providing document bytes and ACLs given a document id
+ *   <li> restricting access to documents
+ * </ol>
  */
 public class AdaptorWithCrawlTimeMetadataTemplate extends AbstractAdaptor {
   private static final Logger log
@@ -44,8 +50,9 @@
   public void getDocIds(DocIdPusher pusher) throws InterruptedException {
     ArrayList<DocId> mockDocIds = new ArrayList<DocId>();
     /* Replace this mock data with code that lists your repository. */
-    mockDocIds.add(new DocId("1001"));
-    mockDocIds.add(new DocId("1002"));
+    mockDocIds.add(new DocId("7007"));
+    mockDocIds.add(new DocId("7007-parent"));
+    mockDocIds.add(new DocId("8008"));
     pusher.pushDocIds(mockDocIds);
   }
 
@@ -54,29 +61,22 @@
   public void getDocContent(Request req, Response resp) throws IOException {
     DocId id = req.getDocId();
     String str;
-    if ("1001".equals(id.getUniqueId())) {
-      str = "Document 1001 says hello and apple orange";
-      List<UserPrincipal> users1001 = Arrays.asList(
-         new UserPrincipal("peter"),
-         new UserPrincipal("bart"), 
-         new UserPrincipal("simon")
-      );
-      List<GroupPrincipal> groups1001 = Arrays.asList(
-         new GroupPrincipal("support"),
-         new GroupPrincipal("sales")
-      );
+    if ("7007".equals(id.getUniqueId())) {
+      str = "Document 7007 is for surviving members of magnificent 7";
       // Add custom meta items.
       resp.addMetadata("my-special-key", "my-custom-value");
       resp.addMetadata("date", "not soon enough");
-      // Must set metadata before getting OutputStream
-      resp.setAcl(new Acl.Builder()
-          // Add user ACL.
-          .setPermitUsers(users1001)
-          // Add group ACL.
-          .setPermitGroups(groups1001)
-          .build());
-    } else if ("1002".equals(id.getUniqueId())) {
-      str = "Document 1002 says hello and banana strawberry";
+      // Add custom acl.
+      resp.setAcl(makeAclFor7007());
+      // Add other attributes.
+      resp.setDisplayUrl(URI.create("https://www.google.com/"));
+    } else if ("7007-parent".equals(id.getUniqueId())) {
+      str = "I have a child named 7007";
+      resp.setAcl(makeAclFor7007Parent());
+      // Add custom meta items.
+      resp.addMetadata("my-day", "parent's day");
+    } else if ("8008".equals(id.getUniqueId())) {
+      str = "Document 8008 says hello and banana strawberry";
       // Must add metadata before getting OutputStream
       resp.addMetadata("date", "never than late");
     } else {
@@ -91,4 +91,81 @@
   public static void main(String[] args) {
     AbstractAdaptor.main(new AdaptorWithCrawlTimeMetadataTemplate(), args);
   }
+  
+  private Acl makeAclFor7007() {
+    List<UserPrincipal> users7007 = Arrays.asList(
+        new UserPrincipal("chris@seven7.google.com"),
+        new UserPrincipal("vin"), 
+        new UserPrincipal("chico")
+    );
+    List<GroupPrincipal> groups7007 = Arrays.asList(
+        new GroupPrincipal("magnificent@seven7.google.com"),
+        new GroupPrincipal("cowboys@seven7.google.com")
+    );
+    List<UserPrincipal> deniedUsers7007 = Arrays.asList(
+        new UserPrincipal("britt"),
+        new UserPrincipal("harry@seven7.google.com"),
+        new UserPrincipal("lee"),
+        new UserPrincipal("bernardo@seven7.google.com"), 
+        new UserPrincipal("calvera")
+    );
+    List<GroupPrincipal> deniedGroups7007 = Arrays.asList(
+        new GroupPrincipal("dead"),
+        new GroupPrincipal("samurai@seven7.google.com", "kurosawa"),
+        new GroupPrincipal("dead", "kurosawa")
+    );
+    return new Acl.Builder()
+        .setPermitUsers(users7007)
+        .setDenyUsers(deniedUsers7007)
+        .setPermitGroups(groups7007)
+        .setDenyGroups(deniedGroups7007)
+        .setInheritFrom(new DocId("7007-parent"))
+        .setEverythingCaseInsensitive()
+        .build();
+  }
+
+  private Acl makeAclFor7007Parent() {
+    return new Acl.Builder()
+        .setInheritanceType(Acl.InheritanceType.PARENT_OVERRIDES)
+        .setPermitUsers(Arrays.asList(new UserPrincipal("vin")))
+        .setDenyUsers(Arrays.asList(new UserPrincipal("chico")))
+        .build();
+  }
+
+  @Override
+  public Map<DocId, AuthzStatus> isUserAuthorized(AuthnIdentity userIdentity,
+      Collection<DocId> ids) throws IOException {
+    Map<DocId, AuthzStatus> result
+        = new HashMap<DocId, AuthzStatus>(ids.size() * 2);
+    for (DocId id : ids) {
+      String uid = id.getUniqueId();
+      if ("7007".equals(uid)) {
+        if (null == userIdentity) {
+          // null for userIdentity means anonymous. To get non-null identity:
+          // 1) Follow instructions for secure mode in src/overview.html
+          // 2) Set config property "server.secure" to "true"
+          // 3) Perform secure search on your GSA (triggers authentication)
+          log.info("no authenticated user found");
+          result.put(id, AuthzStatus.DENY); 
+        } else {
+          List<Acl> acl = Arrays.asList(
+               makeAclFor7007Parent(), makeAclFor7007());
+          result.put(id, Acl.isAuthorized(userIdentity, acl)); 
+        }
+      } else if ("7007-parent".equals(uid)) {
+        if (null == userIdentity) {
+          log.info("no authenticated user found");
+          result.put(id, AuthzStatus.DENY); 
+        } else {
+          List<Acl> acl = Arrays.asList(makeAclFor7007Parent());
+          result.put(id, Acl.isAuthorized(userIdentity, acl)); 
+        }
+      } else if ("8008".equals(id.getUniqueId())) {
+        result.put(id, AuthzStatus.PERMIT); 
+      } else {
+        result.put(id, AuthzStatus.INDETERMINATE);
+      }
+    }
+    return result;
+  }
 }
diff --git a/src/overview.html b/src/overview.html
index a407811..368514d 100644
--- a/src/overview.html
+++ b/src/overview.html
@@ -101,7 +101,8 @@
   <h4>Creating Self-Signed Certificates</h4>
   <p>In the GSA's Admin Console, go to <b>Administration &gt; SSL Settings</b>.
     Under the <b>Create a New SSL Certificate</b> heading change <b>Host
-    Name</b> to the hostname to access the GSA with.  Then click <b>Create
+    Name</b> to GSA's hostname written exactly as the adaptor will use.
+    Then click <b>Create
     Self-Signed Certificate</b> and wait for the operation to complete.
     Then click <b>Install SSL Certificate</b> and wait for that operation
     to complete (about 1 minute).
diff --git a/test/com/google/enterprise/adaptor/DocumentHandlerTest.java b/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
index 179e80f..ad8c464 100644
--- a/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
+++ b/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
@@ -24,6 +24,7 @@
 
 import java.io.*;
 import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.concurrent.atomic.AtomicReference;
@@ -941,10 +942,18 @@
     return new UserPrincipal(name);
   }
 
+  private static UserPrincipal U(String name, String ns) {
+    return new UserPrincipal(name, ns);
+  }
+
   private static GroupPrincipal G(String name) {
     return new GroupPrincipal(name);
   }
 
+  private static GroupPrincipal G(String name, String ns) {
+    return new GroupPrincipal(name, ns);
+  }
+
   @Test
   public void testFormAclHeader() {
     final String golden
@@ -958,7 +967,7 @@
         // by percentEncode to give %2520.
         + "google%3Aaclinheritfrom=http%3A%2F%2Flocalhost%2Fsome%2520docId,"
         + "google%3Aaclinheritancetype=parent-overrides";
-    String result = DocumentHandler.formAclHeader(new Acl.Builder()
+    String result = DocumentHandler.formUnqualifiedAclHeader(new Acl.Builder()
         .setPermitUsers(Arrays.asList(U("pu1"), U("uid=pu2,dc=com")))
         .setPermitGroups(Arrays.asList(G("pg1"), G("gid=pg2,dc=com")))
         .setDenyUsers(Arrays.asList(U("du1"), U("uid=du2,dc=com")))
@@ -970,14 +979,186 @@
   }
 
   @Test
-  public void testFormAclHeaderNull() {
-    assertEquals("", DocumentHandler.formAclHeader(null, new MockDocIdCodec()));
+  public void testFormUnqualifiedAclHeaderNull() {
+    assertEquals("", DocumentHandler
+        .formUnqualifiedAclHeader(null, new MockDocIdCodec()));
+   }
+
+  @Test
+  public void testFormUnqualifiedAclHeaderEmpty() {
+    DocIdEncoder enc = new MockDocIdCodec();
+    assertEquals("google%3Aacldenyusers=google%3AfakeUserToPreventMissingAcl",
+        DocumentHandler.formUnqualifiedAclHeader(Acl.EMPTY, enc));
   }
 
   @Test
-  public void testFormAclHeaderEmpty() {
-    assertEquals("google%3Aacldenyusers=google%3AfakeUserToPreventMissingAcl",
-        DocumentHandler.formAclHeader(Acl.EMPTY, new MockDocIdCodec()));
+  public void testFormNamespacedAclHeaderNull() {
+    assertEquals("", DocumentHandler
+        .formNamespacedAclHeader(null, new MockDocIdCodec()));
+  }
+
+  @Test
+  public void testFormNamespacedAclHeaderEmpty() {
+    DocIdEncoder enc = new MockDocIdCodec();
+    String golden = "{\"entries\":[{"
+        + "\"access\":\"deny\""
+        + ","
+        + "\"name\":\"google:fakeUserToPreventMissingAcl\""
+        + ","
+        + "\"scope\":\"user\""
+        + "}]}";
+    String aclHeader = DocumentHandler.formNamespacedAclHeader(Acl.EMPTY, enc);
+    assertEquals(golden, aclHeader);
+  }
+
+  @Test
+  public void testFormNamespacedAclHeaderBusy() {
+    DocIdEncoder enc = new MockDocIdCodec();
+    String golden = "{"
+        + "\"entries\":["
+            + "{\"access\":\"permit\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"pg1@d.g\",\"scope\":\"group\"},"
+            + "{\"access\":\"permit\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"gid=pg2,dc=m\",\"namespace\":\"ns\","
+                + "\"scope\":\"group\"},"
+            + "{\"access\":\"deny\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"gid=dg2,dc=com\",\"scope\":\"group\"},"
+            + "{\"access\":\"deny\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"dg1@d.g\",\"namespace\":\"ns\","
+                + "\"scope\":\"group\"},"
+            + "{\"access\":\"permit\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"uid=pu2,dc=m\",\"scope\":\"user\"},"
+            + "{\"access\":\"permit\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"pu1@d.g\",\"namespace\":\"ns\","
+                + "\"scope\":\"user\"},"
+            + "{\"access\":\"deny\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"du1@d.g\",\"scope\":\"user\"},"
+            + "{\"access\":\"deny\","
+                + "\"case_sensitivity_type\":\"everything_case_insensitive\","
+                + "\"name\":\"uid=du2,dc=com\",\"namespace\":\"ns\","
+                + "\"scope\":\"user\"}"
+        + "],"
+        + "\"inherit_from\":\"http:\\/\\/localhost\\/some%20docId\","
+        + "\"inheritance_type\":\"PARENT_OVERRIDES\""
+        + "}";
+
+    Acl busyAcl = new Acl.Builder()
+        .setPermitUsers(Arrays.asList(U("pu1@d.g", "ns"), U("uid=pu2,dc=m")))
+        .setPermitGroups(Arrays.asList(G("pg1@d.g"), G("gid=pg2,dc=m", "ns")))
+        .setDenyUsers(Arrays.asList(U("du1@d.g"), U("uid=du2,dc=com", "ns")))
+        .setDenyGroups(Arrays.asList(G("dg1@d.g", "ns"), G("gid=dg2,dc=com")))
+        .setInheritFrom(new DocId("some docId"))
+        .setInheritanceType(Acl.InheritanceType.PARENT_OVERRIDES)
+        .setEverythingCaseInsensitive()
+        .build();
+    String aclHeader = DocumentHandler.formNamespacedAclHeader(busyAcl, enc);
+    assertEquals(golden, aclHeader);
+  }
+
+  @Test
+  public void testDisplayUrlHeader() throws Exception {
+    MockAdaptor adaptor = new MockAdaptor() {
+          @Override
+          public void getDocContent(Request request, Response response)
+              throws IOException {
+            try {
+              response.setDisplayUrl(new URI("http://www.google.com"));
+            } catch (URISyntaxException urie) {
+              throw new RuntimeException(urie);
+            }
+            response.getOutputStream();
+          }
+        };
+    String remoteIp = ex.getRemoteAddress().getAddress().getHostAddress();
+    DocumentHandler handler = createHandlerBuilder()
+        .setAdaptor(adaptor)
+        .setFullAccessHosts(new String[] {remoteIp, "someUnknownHost!@#$"})
+        .setSendDocControls(true)
+        .build();
+    handler.handle(ex);
+    assertEquals(200, ex.getResponseCode());
+    assertTrue(ex.getResponseHeaders().get("X-Gsa-Doc-Controls")
+        .contains("display_url=http%3A%2F%2Fwww.google.com"));
+  }
+
+/*
+  TODO: enable once sending lock is possible at crawl time
+  @Test
+  public void testLockHeaderSent() throws Exception {
+    MockAdaptor adaptor = new MockAdaptor() {
+          @Override
+          public void getDocContent(Request request, Response response)
+              throws IOException {
+            response.setLock(true);
+            response.getOutputStream();
+          }
+        };
+    String remoteIp = ex.getRemoteAddress().getAddress().getHostAddress();
+    DocumentHandler handler = createHandlerBuilder()
+        .setAdaptor(adaptor)
+        .setFullAccessHosts(new String[] {remoteIp, "someUnknownHost!@#$"})
+        .setSendDocControls(true)
+        .build();
+    handler.handle(ex);
+    assertEquals(200, ex.getResponseCode());
+    assertTrue(ex.getResponseHeaders().get("X-Gsa-Doc-Controls")
+        .contains("lock=true"));
+  }
+*/
+
+/*
+  TODO: enable once sending crawl-once is possible at crawl time
+  @Test
+  public void testCrawlOnceHeaderSent() throws Exception {
+    MockAdaptor adaptor = new MockAdaptor() {
+          @Override
+          public void getDocContent(Request request, Response response)
+              throws IOException {
+            response.setCrawlOnce(true);
+            response.getOutputStream();
+          }
+        };
+    String remoteIp = ex.getRemoteAddress().getAddress().getHostAddress();
+    DocumentHandler handler = createHandlerBuilder()
+        .setAdaptor(adaptor)
+        .setFullAccessHosts(new String[] {remoteIp, "someUnknownHost!@#$"})
+        .setSendDocControls(true)
+        .build();
+    handler.handle(ex);
+    assertEquals(200, ex.getResponseCode());
+    assertTrue(ex.getResponseHeaders().get("X-Gsa-Doc-Controls")
+        .contains("crawl-once=true"));
+  }
+*/
+
+  @Test
+  public void testAclMeansServeSecurity() throws Exception {
+    MockAdaptor adaptor = new MockAdaptor() {
+          @Override
+          public void getDocContent(Request request, Response response)
+              throws IOException {
+            response.setAcl(new Acl.Builder()
+                .setInheritFrom(new DocId("testing")).build());
+            response.setSecure(false);
+            response.getOutputStream();
+          }
+        };
+    String remoteIp = ex.getRemoteAddress().getAddress().getHostAddress();
+    DocumentHandler handler = createHandlerBuilder()
+        .setAdaptor(adaptor)
+        .setFullAccessHosts(new String[] {remoteIp, "someUnknownHost!@#$"})
+        .build();
+    handler.handle(ex);
+    assertEquals(200, ex.getResponseCode());
+    assertEquals("secure",
+        ex.getResponseHeaders().getFirst("X-Gsa-Serve-Security"));
   }
 
   @Test
@@ -1013,29 +1194,6 @@
   }
 
   @Test
-  public void testAclMeansServeSecurity() throws Exception {
-    String remoteIp = ex.getRemoteAddress().getAddress().getHostAddress();
-    MockAdaptor adaptor = new MockAdaptor() {
-          @Override
-          public void getDocContent(Request request, Response response)
-              throws IOException {
-            response.setAcl(new Acl.Builder()
-                .setInheritFrom(new DocId("testing")).build());
-            response.setSecure(false);
-            response.getOutputStream();
-          }
-        };
-    DocumentHandler handler = createHandlerBuilder()
-        .setAdaptor(adaptor)
-        .setFullAccessHosts(new String[] {remoteIp, "someUnknownHost!@#$"})
-        .build();
-    handler.handle(ex);
-    assertEquals(200, ex.getResponseCode());
-    assertEquals("secure",
-        ex.getResponseHeaders().getFirst("X-Gsa-Serve-Security"));
-  }
-
-  @Test
   public void testEmulatedAcls() throws Exception {
     String remoteIp = ex.getRemoteAddress().getAddress().getHostAddress();
     final AtomicReference<Acl> providedAcl = new AtomicReference<Acl>();
@@ -1228,6 +1386,7 @@
     private boolean useCompression;
     private Watchdog watchdog;
     private DocumentHandler.AsyncPusher pusher;
+    private boolean sendDocControls;
 
     public DocumentHandlerBuilder setDocIdDecoder(DocIdDecoder docIdDecoder) {
       this.docIdDecoder = docIdDecoder;
@@ -1302,11 +1461,86 @@
       return this;
     }
 
+    public DocumentHandlerBuilder setSendDocControls(boolean sendDocControls) {
+      this.sendDocControls = sendDocControls;
+      return this;
+    }
+
     public DocumentHandler build() {
       return new DocumentHandler(docIdDecoder, docIdEncoder, journal, adaptor,
           gsaHostname, fullAccessHosts, authnHandler, sessionManager, transform,
           transformMaxBytes, transformRequired, useCompression, watchdog,
-          pusher);
+          pusher, sendDocControls);
     }
   }
+
+  /** percentDecode method is defined in this test file */
+  @Test
+  public void testPercentDecoder() {
+    assertEquals("" + ((char)(10)), percentDecode("%0A"));
+    for (char c = 0; c < 256; c++) {
+      String encoded = DocumentHandler.percentEncode("" + c);
+      String decoded = percentDecode(encoded);
+      if (!decoded.equals("" + c)) {
+        int n = c;
+        throw new AssertionError("failed to encode/decode `" + n
+            + "' `" + c + "' `" + encoded + "' `" + decoded + "'");
+      }
+    }
+    String decoded = percentDecode("AaZz09-_.~"
+        + "%60%3D%2F%3F%2B%27%3B%5C%2F%22%21%40%23%24%25%5E%26"
+        + "%2A%28%29%5B%5D%7B%7D%C3%AB%01");
+    assertEquals("AaZz09-_.~`=/?+';\\/\"!@#$%^&*()[]{}ë\u0001", decoded);
+  }
+ 
+  private static int hexToInt(byte b) {
+    if (b >= '0' && b <= '9') {
+      return (byte)(b - '0');
+    } else if (b >= 'a' && b <= 'f') {
+      return (byte)(b - 'a') + 10;
+    } else if (b >= 'A' && b <= 'F') {
+      return (byte)(b - 'A') + 10;
+    } else {
+      throw new IllegalArgumentException("invalid hex byte: " + b);
+    }
+  }
+
+  private static String percentDecode(String encoded) {
+    try {
+      byte bytes[] = encoded.getBytes("ASCII");
+      ByteArrayOutputStream decoded = percentDecode(bytes);
+      return decoded.toString("UTF-8"); 
+    } catch (UnsupportedEncodingException uee) {
+      throw new RuntimeException(uee);
+    }
+  }
+
+  private static ByteArrayOutputStream percentDecode(byte encoded[]) {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    int i = 0;
+    while (i < encoded.length) {
+      byte b = encoded[i];
+      if (b == '%') {
+        int iNeeded = i + 2;  // need two more bytes
+        if (iNeeded >= encoded.length) {
+          throw new IllegalArgumentException("ends too early");
+        }
+        int highOrder = hexToInt(encoded[i + 1]);
+        int lowOrder = hexToInt(encoded[i + 2]);
+        int byteInInt = (highOrder << 4) | lowOrder;
+        b = (byte) byteInInt;  // chops top bytes; could make negative
+        i += 3;
+      } else if ((b >= 'a' && b <= 'z')
+          || (b >= 'A' && b <= 'Z')
+          || (b >= '0' && b <= '9')
+          || b == '-' || b == '_' || b == '.' || b == '~') {
+        // pass through
+        i++; 
+      } else {
+        throw new IllegalArgumentException("not percent encoded");
+      }
+      out.write(b);
+    }
+    return out;
+  }
 }