public abstract class AuthProvider {

  private static Log sLog = LogFactory.getLog(AuthProvider.class);

  // registered/installed providers
  private static Map<String, AuthProvider> registeredProviders =
      new HashMap<String, AuthProvider>();

  // ordered list of enabled providers
  private static List<AuthProvider> enabledProviders = null;

  static {
    register(new ZimbraAuthProvider());
    // register(new ZimbraOAuthProvider());
    refresh();
  }

  public static synchronized void register(AuthProvider ap) {
    String name = ap.getName();
    logger().info("Adding auth provider: " + name + " " + ap.getClass().getName());

    if (registeredProviders.get(name) == null) {
      registeredProviders.put(name, ap);
    } else {
      logger()
          .error(
              "auth provider " + name + " already exists, not adding " + ap.getClass().getName());
    }
  }

  /**
   * refresh the enabled cache
   *
   * <p>TODO, can be called from zmprov flushCache to flush the enabled cache
   */
  public static void refresh() {
    List<AuthProvider> providerList = new ArrayList<AuthProvider>();
    String[] providers = LC.zimbra_auth_provider.value().split(",");
    for (String provider : providers) {

      /*
       * ignore zimbra and oauth auth providers, they are built-in providers.
       *
       * If no external auth providers is configured, zimbra and oauth providers
       * will be used.
       */
      if (ZimbraAuthProvider.ZIMBRA_AUTH_PROVIDER.equals(provider)
          || ZimbraOAuthProvider.ZIMBRA_OAUTH_PROVIDER.equals(provider)) {
        continue;
      }

      provider = provider.trim();
      if (!Strings.isNullOrEmpty(provider)) {
        AuthProvider ap = registeredProviders.get(provider);
        if (ap != null) {
          providerList.add(ap);
        }
      }
    }

    // always add the zimbra providers if there is no provider configured.
    if (providerList.size() == 0) {
      providerList.add(registeredProviders.get(ZimbraAuthProvider.ZIMBRA_AUTH_PROVIDER));
      // providerList.add(registeredProviders.get(ZimbraOAuthProvider.ZIMBRA_OAUTH_PROVIDER));
    }

    setProviders(providerList);
  }

  private static synchronized void setProviders(List<AuthProvider> providers) {
    enabledProviders = providers;
  }

  private static synchronized List<AuthProvider> getProviders() {
    return enabledProviders;
  }

  private String mName;

  protected AuthProvider(String name) {
    mName = name;
  }

  private String getName() {
    return mName;
  }

  protected static Log logger() {
    return sLog;
  }

  /**
   * Returns an AuthToken by auth data in http request
   *
   * <p>Should never return null. Throws AuthProviderException.NO_AUTH_TOKEN if auth data for the
   * provider is not present Throws AuthTokenException if auth data for the provider is present but
   * cannot be resolved into a valid AuthToken
   *
   * @param req
   * @param isAdmin
   * @return
   * @throws AuthTokenException
   */
  protected abstract AuthToken authToken(HttpServletRequest req, boolean isAdminReq)
      throws AuthProviderException, AuthTokenException;

  /**
   * Returns an AuthToken by auth data in http request
   *
   * <p>Should never return null. Throws AuthProviderException.NO_AUTH_TOKEN if auth data for the
   * provider is not present Throws AuthTokenException if auth data for the provider is present but
   * cannot be resolved into a valid AuthToken
   *
   * @param soapCtxt
   * @param engineCtxt
   * @param isAdmin
   * @return
   * @throws AuthTokenException
   */
  protected abstract AuthToken authToken(Element soapCtxt, Map engineCtxt)
      throws AuthProviderException, AuthTokenException;

  /**
   * Returns an AuthToken by auth data in the <authToken> element.
   *
   * @param authTokenElem
   * @param acct TODO: This may not be needed if we can get account info from the OAuth access
   *     token.
   * @return
   * @throws AuthProviderException
   * @throws AuthTokenException
   */
  protected AuthToken authToken(Element authTokenElem, Account acct)
      throws AuthProviderException, AuthTokenException {
    // default implementation just extracts the text and calls the authToken(String)
    // the acct parameter is ignored.
    String token = authTokenElem.getText();
    return authToken(token);
  }

  /**
   * Returns an AuthToken from an encoded String.
   *
   * <p>This API is for servlets that support auth from a non-cookie channel, where it honors a
   * String token from a specific element in the request, which is neither a cookie nor a SOAP
   * context header. e.g. a query param.
   *
   * <p>By default, an AuthProvider do not need to implement this method. The default implementation
   * is throwing AuthProviderException.NOT_SUPPORTED.
   *
   * <p>Should never return null. Throws AuthProviderException.NO_SUPPORTED if this API is not
   * supported by the auth provider Throws AuthProviderException.NO_AUTH_TOKEN if auth data for the
   * provider is not present Throws AuthTokenException if auth data for the provider is present but
   * cannot be resolved into a valid AuthToken
   *
   * @param encoded
   * @return
   * @throws AuthProviderException
   * @throws AuthTokenException
   */
  protected AuthToken authToken(String encoded) throws AuthProviderException, AuthTokenException {
    throw AuthProviderException.NOT_SUPPORTED();
  }

  /**
   * Returns an AuthToken from the account object. Should never return null.
   *
   * @param acct
   * @return
   * @throws AuthProviderException
   */
  protected AuthToken authToken(Account acct) throws AuthProviderException {
    return authToken(acct, false, null);
  }

  /**
   * Returns an AuthToken from the account object. Should never return null.
   *
   * @param acct
   * @param isAdmin
   * @param authMech
   * @return
   * @throws AuthProviderException
   */
  protected AuthToken authToken(Account acct, boolean isAdmin, AuthMech authMech)
      throws AuthProviderException {
    if (acct == null) {
      throw AuthProviderException.NOT_SUPPORTED();
    }

    long lifetime =
        isAdmin
            ? acct.getTimeInterval(
                Provisioning.A_zimbraAdminAuthTokenLifetime, AuthToken.DEFAULT_AUTH_LIFETIME * 1000)
            : acct.getTimeInterval(
                Provisioning.A_zimbraAuthTokenLifetime, AuthToken.DEFAULT_AUTH_LIFETIME * 1000);
    return authToken(acct, lifetime);
  }

  /**
   * Returns an AuthToken from the account object with specified lifetime. Should never return null.
   *
   * @param acct
   * @param expires
   * @return
   * @throws AuthProviderException
   */
  protected AuthToken authToken(Account acct, long expires) throws AuthProviderException {
    throw AuthProviderException.NOT_SUPPORTED();
  }

  /**
   * Returns an AuthToken from the account object with specified expiration. Should never return
   * null.
   *
   * @param acct
   * @param expires
   * @param isAdmin
   * @param adminAcct
   * @param acct account authtoken will be valid for
   * @param expires when the token expires
   * @param isAdmin true if acct is using its admin privileges
   * @param adminAcct the admin account accessing acct's information, if this token was created by
   *     an admin.
   * @return
   * @throws AuthProviderException
   */
  protected AuthToken authToken(Account acct, long expires, boolean isAdmin, Account adminAcct)
      throws AuthProviderException {
    throw AuthProviderException.NOT_SUPPORTED();
  }

  /**
   * @param req
   * @return whether http basic authentication is allowed
   */
  protected boolean allowHttpBasicAuth(HttpServletRequest req, ZimbraServlet servlet) {
    return true;
  }

  /**
   * @param req
   * @return whether accesskey authentication is allowed
   */
  protected boolean allowURLAccessKeyAuth(HttpServletRequest req, ZimbraServlet servlet) {
    return false;
  }

  /**
   * The static getAuthToken methods go through all the providers, trying them in order until one
   * returns an AuthToken. Return null when there is no auth data for any of the enabled providers
   * Throw AuthTokenException if any provider in the chain throws an AuthTokenException.
   *
   * <p>Note: 1. we proceed to try the next provider if the provider throws an AuthProviderException
   * that is ignorable (AuthProviderException.canIgnore()). for example:
   * AuthProviderException.NO_AUTH_TOKEN, AuthProviderException.NOT_SUPPORTED.
   *
   * <p>2. in all other cases, we stop processing and throws an AuthTokenException to our caller. In
   * particular, when a provider: - returns null -> it should not. Treat it as a provider error and
   * throws AuthTokenException - throws AuthTokenException, this means auth data is present for a
   * provider but it cannot be resolved into a valid AuthToken. - Any AuthProviderException that is
   * not ignorable.
   */

  /**
   * @param req http request
   * @return an AuthToken object, or null if auth data is not present for any of the enabled
   *     providers
   * @throws ServiceException
   */
  public static AuthToken getAuthToken(HttpServletRequest req, boolean isAdminReq)
      throws AuthTokenException {
    AuthToken at = null;
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        at = ap.authToken(req, isAdminReq);
        if (at == null) {
          throw new AuthTokenException("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        // if there is no auth data for this provider, log and continue with next provider
        if (e.canIgnore()) {
          logger().debug(ap.getName() + ":" + e.getMessage());
        } else {
          throw new AuthTokenException("auth provider error", e);
        }
      } catch (AuthTokenException e) {
        // log and rethrow
        logger()
            .debug("getAuthToken error: provider=" + ap.getName() + ", err=" + e.getMessage(), e);
        throw e;
      }
    }

    // there is no auth data for any of the enabled providers
    return null;
  }

  /**
   * For SOAP, we do not pass in isAdminReq, because with the current flow in SoapEngine, at the
   * point when the SOAP context(ZimbraSoapContext) is examined, we haven't looked at the SOAP body
   * yet. Whether admin auth is required is based on the SOAP command, which has to be extracted
   * from the body. ZimbraAuthProvider always retrieves the encoded auth token from the fixed tag,
   * so does YahooYT auth. This should be fine for now.
   *
   * @param soapCtxt <context> element in SOAP header
   * @param engineCtxt soap engine context
   * @return an AuthToken object, or null if auth data is not present for any of the enabled
   *     providers
   * @throws AuthTokenException
   */
  public static AuthToken getAuthToken(Element soapCtxt, Map engineCtxt) throws AuthTokenException {
    AuthToken at = null;
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        at = ap.authToken(soapCtxt, engineCtxt);
        if (at == null) {
          throw new AuthTokenException("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        // if there is no auth data for this provider, log and continue with next provider
        if (e.canIgnore()) {
          logger().debug(ap.getName() + ":" + e.getMessage());
        } else {
          throw new AuthTokenException("auth provider error", e);
        }
      } catch (AuthTokenException e) {
        // log and rethrow
        logger()
            .debug("getAuthToken error: provider=" + ap.getName() + ", err=" + e.getMessage(), e);
        throw e;
      }
    }

    // there is no auth data for any of the enabled providers
    return null;
  }

  public static AuthToken getAuthToken(Element authTokenElem, Account acct)
      throws AuthTokenException {
    AuthToken at = null;
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        at = ap.authToken(authTokenElem, acct);
        if (at == null) {
          throw new AuthTokenException("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        // if there is no auth data for this provider, log and continue with next provider
        if (e.canIgnore()) {
          logger().debug(ap.getName() + ":" + e.getMessage());
        } else {
          throw new AuthTokenException("auth provider error", e);
        }
      } catch (AuthTokenException e) {
        // log and rethrow
        logger()
            .debug("getAuthToken error: provider=" + ap.getName() + ", err=" + e.getMessage(), e);
        throw e;
      }
    }

    // there is no auth data for any of the enabled providers
    return null;
  }

  /**
   * Creates an AuthToken object from token string.
   *
   * @param encoded
   * @return
   * @throws AuthTokenException
   * @see #authToken(String)
   */
  public static AuthToken getAuthToken(String encoded) throws AuthTokenException {
    AuthToken at = null;
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        at = ap.authToken(encoded);
        if (at == null) {
          throw new AuthTokenException("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        // if there is no auth data for this provider, log and continue with next provider
        if (e.canIgnore()) {
          logger().warn(ap.getName() + ":" + e.getMessage());
        } else {
          throw new AuthTokenException("auth provider error", e);
        }
      } catch (AuthTokenException e) {
        // log and rethrow
        logger()
            .debug("getAuthToken error: provider=" + ap.getName() + ", err=" + e.getMessage(), e);
        throw e;
      }
    }

    // there is no auth data for any of the enabled providers
    logger().error("unable to get AuthToken from encoded " + encoded);
    return null;
  }

  public static AuthToken getAuthToken(Account acct) throws AuthProviderException {
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        AuthToken at = ap.authToken(acct);
        if (at == null) {
          throw AuthProviderException.FAILURE("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        if (e.canIgnore()) {
          logger().debug(ap.getName() + ":" + e.getMessage());
        } else {
          throw e;
        }
      }
    }

    throw AuthProviderException.FAILURE("cannot get authtoken from account " + acct.getName());
  }

  public static AuthToken getAuthToken(Account acct, boolean isAdmin) throws AuthProviderException {
    return getAuthToken(acct, isAdmin, null);
  }

  public static AuthToken getAuthToken(Account acct, boolean isAdmin, AuthMech authMech)
      throws AuthProviderException {
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        AuthToken at = ap.authToken(acct, isAdmin, authMech);
        if (at == null) {
          throw AuthProviderException.FAILURE("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        if (e.canIgnore()) {
          logger().debug(ap.getName() + ":" + e.getMessage());
        } else {
          throw e;
        }
      }
    }

    String acctName = acct != null ? acct.getName() : "null";
    throw AuthProviderException.FAILURE("cannot get authtoken from account " + acctName);
  }

  public static AuthToken getAdminAuthToken() throws ServiceException {
    Account acct = Provisioning.getInstance().get(AccountBy.adminName, LC.zimbra_ldap_user.value());
    return AuthProvider.getAuthToken(acct, true);
  }

  public static AuthToken getAuthToken(Account acct, long expires) throws AuthProviderException {
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        AuthToken at = ap.authToken(acct, expires);
        if (at == null) {
          throw AuthProviderException.FAILURE("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        if (e.canIgnore()) {
          logger().debug(ap.getName() + ":" + e.getMessage());
        } else {
          throw e;
        }
      }
    }

    throw AuthProviderException.FAILURE("cannot get authtoken from account " + acct.getName());
  }

  public static AuthToken getAuthToken(
      Account acct, long expires, boolean isAdmin, Account adminAcct) throws AuthProviderException {
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      try {
        AuthToken at = ap.authToken(acct, expires, isAdmin, adminAcct);
        if (at == null) {
          throw AuthProviderException.FAILURE("auth provider " + ap.getName() + " returned null");
        } else {
          return at;
        }
      } catch (AuthProviderException e) {
        if (e.canIgnore()) {
          logger().debug(ap.getName() + ":" + e.getMessage());
        } else {
          throw e;
        }
      }
    }

    throw AuthProviderException.FAILURE("cannot get authtoken from account " + acct.getName());
  }

  public static boolean allowBasicAuth(HttpServletRequest req, ZimbraServlet servlet) {
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      if (ap.allowHttpBasicAuth(req, servlet)) {
        return true;
      }
    }
    return false;
  }

  public static boolean allowAccessKeyAuth(HttpServletRequest req, ZimbraServlet servlet) {
    List<AuthProvider> providers = getProviders();
    for (AuthProvider ap : providers) {
      if (ap.allowURLAccessKeyAuth(req, servlet)) {
        return true;
      }
    }
    return false;
  }

  public static Account validateAuthToken(
      Provisioning prov, AuthToken at, boolean addToLoggingContext) throws ServiceException {
    try {
      return validateAuthTokenInternal(prov, at, addToLoggingContext);
    } catch (ServiceException e) {
      if (ServiceException.AUTH_EXPIRED.equals(e.getCode())) {
        // we may not want to expose the details to malicious caller
        // debug log the message and throw a vanilla AUTH_EXPIRED
        ZimbraLog.account.debug("auth token validation failed", e);
        throw ServiceException.AUTH_EXPIRED();
      } else {
        // rethrow the same exception
        throw e;
      }
    }
  }

  private static Account validateAuthTokenInternal(
      Provisioning prov, AuthToken at, boolean addToLoggingContext) throws ServiceException {
    if (prov == null) {
      prov = Provisioning.getInstance();
    }

    if (at.isExpired()) {
      throw ServiceException.AUTH_EXPIRED();
    }

    // make sure that the authenticated account is still active and has not been deleted since the
    // last request
    String acctId = at.getAccountId();
    Account acct = prov.get(AccountBy.id, acctId, at);

    if (acct == null) {
      throw ServiceException.AUTH_EXPIRED("account " + acctId + " not found");
    }

    if (addToLoggingContext) {
      ZimbraLog.addAccountNameToContext(acct.getName());
    }

    if (!acct.checkAuthTokenValidityValue(at)) {
      throw ServiceException.AUTH_EXPIRED("invalid validity value");
    }

    boolean delegatedAuth = at.isDelegatedAuth();
    String acctStatus = acct.getAccountStatus(prov);

    if (!delegatedAuth && !Provisioning.ACCOUNT_STATUS_ACTIVE.equals(acctStatus)) {
      throw ServiceException.AUTH_EXPIRED("account not active");
    }

    // if using delegated auth, make sure the "admin" is really an active admin account
    if (delegatedAuth) {

      // note that delegated auth allows access unless the account's in maintenance mode
      if (Provisioning.ACCOUNT_STATUS_MAINTENANCE.equals(acctStatus)) {
        throw ServiceException.AUTH_EXPIRED("delegated account in MAINTENANCE mode");
      }

      Account admin = prov.get(AccountBy.id, at.getAdminAccountId());
      if (admin == null) {
        throw ServiceException.AUTH_EXPIRED(
            "delegating account " + at.getAdminAccountId() + " not found");
      }

      boolean isAdmin = AdminAccessControl.isAdequateAdminAccount(admin);
      if (!isAdmin) {
        throw ServiceException.PERM_DENIED("not an admin for delegated auth");
      }

      if (!Provisioning.ACCOUNT_STATUS_ACTIVE.equals(admin.getAccountStatus(prov))) {
        throw ServiceException.AUTH_EXPIRED("delegating account is not active");
      }
    }

    return acct;
  }
}
Example #2
0
/**
 * Caches uncompressed versions of compressed files on disk. Uses the digest of the file internally
 * to dedupe multiple copies of the same data. The cache size can be limited by the number of files
 * or the total size.
 *
 * @param <K> the type of key used to look up files in the cache
 */
public class UncompressedFileCache<K> {

  private static final Log sLog = LogFactory.getLog(UncompressedFileCache.class);

  private File mCacheDir;

  /** Maps the key to the cache to the uncompressed file digest. */
  private Map<K, String> mKeyToDigest;

  /** Reverse map of the digest to all the keys that reference it. */
  private Map<String, Set<K>> mDigestToKeys;

  /** All the files in the cache, indexed by digest. */
  private LinkedHashMap<String, File> mDigestToFile;

  private long mNumBytes = 0;

  public UncompressedFileCache(String path) {
    if (path == null) {
      throw new NullPointerException("Path cannot be null.");
    }
    mCacheDir = new File(path);
  }

  /**
   * Initializes the cache and deletes any existing files. Call this method before using the cache.
   */
  public synchronized UncompressedFileCache<K> startup() throws IOException {
    if (!mCacheDir.exists())
      throw new IOException("uncompressed file cache folder does not exist: " + mCacheDir);
    if (!mCacheDir.isDirectory())
      throw new IOException("uncompressed file cache folder is not a directory: " + mCacheDir);

    // Create caches with default LinkedHashMap values, but sorted by last access time.
    mKeyToDigest = new HashMap<K, String>();
    mDigestToKeys = new HashMap<String, Set<K>>();
    mDigestToFile = new LinkedHashMap<String, File>(16, 0.75f, true);

    for (File file : mCacheDir.listFiles()) {
      sLog.debug("Deleting %s.", file.getPath());
      if (!file.delete())
        ZimbraLog.store.warn(
            "unable to delete " + file.getPath() + " from uncompressed file cache");
    }
    return this;
  }

  private class UncompressedFile {
    String digest;
    File file;

    UncompressedFile() {}
  }

  /**
   * Returns the uncompressed version of the given file. If the uncompressed file is not in the
   * cache, uncompresses it and adds it to the cache.
   *
   * @param key the key used to look up the uncompressed data
   * @param compressedFile the compressed file. This file is read, if necessary, to write the
   *     uncompressed file.
   * @param sync <tt>true</tt> to use fsync
   */
  public SharedFile get(K key, File compressedFile, boolean sync) throws IOException {
    File uncompressedFile = null;

    sLog.debug("Looking up SharedFile for key %s, path %s.", key, compressedFile.getPath());

    synchronized (this) {
      String digest = mKeyToDigest.get(key);
      sLog.debug("Digest for %s is %s", key, digest);
      if (digest != null) {
        uncompressedFile = mDigestToFile.get(digest);
        if (uncompressedFile != null) {
          sLog.debug("Found existing uncompressed file.  Returning new SharedFile.");
          return new SharedFile(uncompressedFile);
        } else {
          sLog.debug("No existing uncompressed file.");
        }
      }
    }

    // Uncompress the file outside of the synchronized block.
    UncompressedFile temp = uncompressToTempFile(compressedFile, sync);
    SharedFile shared = null;

    synchronized (this) {
      uncompressedFile = mDigestToFile.get(temp.digest);

      if (uncompressedFile != null) {
        // Another thread uncompressed the same file at the same time.
        sLog.debug("Found existing uncompressed file.  Deleting %s.", temp.file);
        mapKeyToDigest(key, temp.digest);
        FileUtil.delete(temp.file);
        shared = new SharedFile(uncompressedFile);
      } else {
        uncompressedFile = new File(mCacheDir, temp.digest);
        sLog.debug("Renaming %s to %s.", temp.file, uncompressedFile);
        FileUtil.rename(temp.file, uncompressedFile);
        shared = new SharedFile(uncompressedFile); // Opens the file implicitly.
        put(key, temp.digest, uncompressedFile);
      }
    }

    return shared;
  }

  private UncompressedFile uncompressToTempFile(File compressedFile, boolean sync)
      throws IOException {
    // Write the uncompressed file and calculate the digest.
    CalculatorStream calc =
        new CalculatorStream(new GZIPInputStream(new FileInputStream(compressedFile)));
    File tempFile =
        File.createTempFile(UncompressedFileCache.class.getSimpleName(), null, mCacheDir);
    FileUtil.uncompress(calc, tempFile, sync);
    String digest = calc.getDigest();
    sLog.debug(
        "Uncompressed %s to %s, digest=%s.", compressedFile.getPath(), tempFile.getPath(), digest);

    UncompressedFile result = new UncompressedFile();
    result.file = tempFile;
    result.digest = digest;
    return result;
  }

  /** Creates a record of a new uncompressed file in the cache data structures. */
  private synchronized void put(K key, String digest, File file) {
    long fileSize = file.length();
    mapKeyToDigest(key, digest);
    mDigestToFile.put(digest, file);
    mNumBytes += fileSize;
    sLog.debug(
        "Added file: key=%s, size=%d, path=%s.  Cache size=%d, numBytes=%d.",
        key, fileSize, file.getPath(), mDigestToFile.size(), mNumBytes);
  }

  private synchronized void mapKeyToDigest(K key, String digest) {
    mKeyToDigest.put(key, digest);
    Set<K> keys = mDigestToKeys.get(digest);
    if (keys == null) {
      keys = new HashSet<K>();
      mDigestToKeys.put(digest, keys);
    }
    keys.add(key);
  }

  public synchronized void remove(K key) {
    String digest = mKeyToDigest.remove(key);
    sLog.debug("Removing %s, digest=%s", key, digest);

    if (digest != null) {
      Set<K> keys = mDigestToKeys.get(digest);
      if (keys != null) {
        keys.remove(key);
      }

      if (keys == null || keys.isEmpty()) {
        File file = mDigestToFile.remove(digest);
        mDigestToKeys.remove(digest);
        sLog.debug("Deleting unreferenced file %s.", file);
        if (file != null) {
          try {
            FileUtil.delete(file);
          } catch (Exception e) { // IOException and SecurityException
            ZimbraLog.store.warn("Unable to remove a file from the uncompressed cache.", e);
          }
        }
      } else {
        sLog.debug("Not deleting %s.  It is referenced by %s.", digest, keys);
      }
    }
  }
}