/*
* CDDL HEADER START
*
* The contents of this file are subject to the terms of the
* Common Development and Distribution License, Version 1.0 only
* (the "License"). You may not use this file except in compliance
* with the License.
*
* You can obtain a copy of the license at
* trunk/opends/resource/legal-notices/OpenDS.LICENSE
* or https://OpenDS.dev.java.net/OpenDS.LICENSE.
* See the License for the specific language governing permissions
* and limitations under the License.
*
* When distributing Covered Code, include this CDDL HEADER in each
* file and include the License file at
* trunk/opends/resource/legal-notices/OpenDS.LICENSE. If applicable,
* add the following below this CDDL HEADER, with the fields enclosed
* by brackets "[]" replaced with your own identifying information:
* Portions Copyright [yyyy] [name of copyright owner]
*
* CDDL HEADER END
*
*
* Copyright 2010 Sun Microsystems, Inc.
*/
package org.opends.sdk;
import static com.sun.opends.sdk.messages.Messages.*;
import java.util.*;
import org.opends.sdk.requests.Requests;
import org.opends.sdk.requests.SearchRequest;
import org.opends.sdk.schema.Schema;
import com.sun.opends.sdk.util.StaticUtils;
import com.sun.opends.sdk.util.Validator;
/**
* An LDAP URL as defined in RFC 4516. In addition, the secure ldap (ldaps://)
* is also supported. LDAP URLs have the following format:
*
*
* "ldap[s]://" [ hostName [":" portNumber] ]
* "/" distinguishedName
* ["?" attributeList
* ["?" scope "?" filterString ] ]
*
*
* Where:
*
* - all text within double-quotes are literal
*
hostName and portNumber identify
* the location of the LDAP server.
* distinguishedName is the name of an entry within the
* given directory (the entry represents the starting point of the search).
* attributeList contains a list of attributes to
* retrieve (if null, fetch all attributes). This is a comma-delimited list of
* attribute names.
* scope is one of the following:
*
* base indicates that this is a search only for the specified
* entry
* one indicates that this is a search for matching entries one
* level under the specified entry (and not including the entry itself)
* sub indicates that this is a search for matching entries at
* all levels under the specified entry (including the entry itself)
* subordinates indicates that this is a search for matching
* entries all levels under the specified entry (excluding the entry itself)
*
* If not specified, scope is base by default.
* filterString is a human-readable representation of
* the search criteria. If no filter is provided, then a default of "{@code
* (objectClass=*)}" should be assumed.
*
* The same encoding rules for other URLs (e.g. HTTP) apply for LDAP URLs.
* Specifically, any "illegal" characters are escaped with
* %HH, where HH represent the two hex
* digits which correspond to the ASCII value of the character. This encoding is
* only legal (or necessary) on the DN and filter portions of the URL.
*
* Note that this class does not implement extensions.
*
* @see RFC 4516 - Lightweight
* Directory Access Protocol (LDAP): Uniform Resource Locator
*/
public final class LDAPUrl
{
/**
* The scheme corresponding to an LDAP URL. RFC 4516 mandates only ldap scheme
* but we support "ldaps" too.
*/
private final boolean isSecured;
/**
* The host name corresponding to an LDAP URL.
*/
private final String host;
/**
* The port number corresponding to an LDAP URL.
*/
private final int port;
/**
* The distinguished name corresponding to an LDAP URL.
*/
private final DN name;
/**
* The search scope corresponding to an LDAP URL.
*/
private final SearchScope scope;
/**
* The search filter corresponding to an LDAP URL.
*/
private final Filter filter;
/**
* The attributes that need to be searched.
*/
private final List attributes;
/**
* The String value of LDAP URL.
*/
private final String urlString;
/**
* Normalized ldap URL.
*/
private String normalizedURL;
/**
* The default scheme to be used with LDAP URL.
*/
private static final String DEFAULT_URL_SCHEME = "ldap";
/**
* The SSL-based scheme allowed to be used with LDAP URL.
*/
private static final String SSL_URL_SCHEME = "ldaps";
/**
* The default host.
*/
private static final String DEFAULT_HOST = "localhost";
/**
* The default non-SSL port.
*/
private static final int DEFAULT_PORT = 389;
/**
* The default SSL port.
*/
private static final int DEFAULT_SSL_PORT = 636;
/**
* The default filter.
*/
private static final Filter DEFAULT_FILTER = Filter
.getObjectClassPresentFilter();
/**
* The default search scope.
*/
private static final SearchScope DEFAULT_SCOPE = SearchScope.BASE_OBJECT;
/**
* The default distinguished name.
*/
private static final DN DEFAULT_DN = DN.rootDN();
/**
* The % encoding character.
*/
private static final char PERCENT_ENCODING_CHAR = '%';
/**
* The ? character.
*/
private static final char QUESTION_CHAR = '?';
/**
* The slash (/) character.
*/
private static final char SLASH_CHAR = '/';
/**
* The comma (,) character.
*/
private static final char COMMA_CHAR = ',';
/**
* The colon (:) character.
*/
private static final char COLON_CHAR = ':';
/**
* Set containing characters that do not need to be encoded.
*/
private static final Set VALID_CHARS = new HashSet();
static
{
// Refer to RFC 3986 for more details.
final char[] delims = { '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';',
'=', '.', '-', '_', '~' };
for (final char c : delims)
{
VALID_CHARS.add(c);
}
for (char c = 'a'; c <= 'z'; c++)
{
VALID_CHARS.add(c);
}
for (char c = 'A'; c <= 'Z'; c++)
{
VALID_CHARS.add(c);
}
for (char c = '0'; c <= '9'; c++)
{
VALID_CHARS.add(c);
}
}
/**
* Parses the provided LDAP string representation of an LDAP URL using the
* default schema.
*
* @param url
* The LDAP string representation of an LDAP URL.
* @return The parsed LDAP URL.
* @throws LocalizedIllegalArgumentException
* If {@code url} is not a valid LDAP string representation of an
* LDAP URL.
* @throws NullPointerException
* If {@code url} was {@code null}.
*/
public static LDAPUrl valueOf(final String url)
throws LocalizedIllegalArgumentException, NullPointerException
{
return valueOf(url, Schema.getDefaultSchema());
}
/**
* Parses the provided LDAP string representation of an LDAP URL using the
* provided schema.
*
* @param url
* The LDAP string representation of an LDAP URL.
* @param schema
* The schema to use when parsing the LDAP URL.
* @return The parsed LDAP URL.
* @throws LocalizedIllegalArgumentException
* If {@code url} is not a valid LDAP string representation of an
* LDAP URL.
* @throws NullPointerException
* If {@code url} or {@code schema} was {@code null}.
*/
public static LDAPUrl valueOf(final String url, final Schema schema)
throws LocalizedIllegalArgumentException, NullPointerException
{
Validator.ensureNotNull(url, schema);
return new LDAPUrl(url, schema);
}
private static int decodeHex(final String url, final int index,
final char hexChar) throws LocalizedIllegalArgumentException
{
if (hexChar >= '0' && hexChar <= '9')
{
return hexChar - '0';
}
else if (hexChar >= 'A' && hexChar <= 'F')
{
return hexChar - 'A' + 10;
}
else if (hexChar >= 'a' && hexChar <= 'f')
{
return hexChar - 'a' + 10;
}
final LocalizableMessage msg = ERR_LDAPURL_INVALID_HEX_BYTE.get(url, index);
throw new LocalizedIllegalArgumentException(msg);
}
private static void percentDecoder(final String urlString, final int index,
final String s, final StringBuilder decoded)
throws LocalizedIllegalArgumentException
{
Validator.ensureNotNull(s, decoded);
decoded.append(s);
int srcPos = 0, dstPos = 0;
while (srcPos < decoded.length())
{
if (decoded.charAt(srcPos) != '%')
{
if (srcPos != dstPos)
{
decoded.setCharAt(dstPos, decoded.charAt(srcPos));
}
srcPos++;
dstPos++;
continue;
}
decoded.setCharAt(dstPos, (char) ((decodeHex(urlString, index + srcPos
+ 1, decoded.charAt(srcPos + 1)) << 4) | (decodeHex(urlString, index
+ srcPos + 2, decoded.charAt(srcPos + 2)))));
dstPos++;
srcPos += 3;
}
decoded.setLength(dstPos);
}
/**
* This method performs the percent-encoding as defined in section 2.1 of RFC
* 3986.
*
* @param urlElement
* The element of the URL that needs to be percent encoded.
* @param encodedBuffer
* The buffer that contains the final percent encoded value.
*/
private static void percentEncoder(final String urlElement,
final StringBuilder encodedBuffer)
{
Validator.ensureNotNull(urlElement);
for (int count = 0; count < urlElement.length(); count++)
{
final char c = urlElement.charAt(count);
if (VALID_CHARS.contains(c))
{
encodedBuffer.append(c);
}
else
{
encodedBuffer.append(PERCENT_ENCODING_CHAR);
encodedBuffer.append(Integer.toHexString(c));
}
}
}
/**
* Creates a new LDAP URL referring to a single entry on the specified server.
* The LDAP URL with have base object scope and the filter {@code
* (objectClass=*)}.
*
* @param isSecured
* {@code true} if this LDAP URL should use LDAPS or {@code false} if
* it should use LDAP.
* @param host
* The name or IP address in dotted format of the LDAP server. For
* example, {@code ldap.server1.com} or {@code 192.202.185.90}. Use
* {@code null} for the local host.
* @param port
* The port number of the LDAP server, or {@code null} to use the
* default port (389 for LDAP and 636 for LDAPS).
* @param name
* The distinguished name of the base entry relative to which the
* search is to be performed, or {@code null} to specify the root
* DSE.
* @throws LocalizedIllegalArgumentException
* If {@code port} was less than 1 or greater than 65535.
*/
public LDAPUrl(final boolean isSecured, final String host,
final Integer port, final DN name)
throws LocalizedIllegalArgumentException
{
this(isSecured, host, port, name, DEFAULT_SCOPE, DEFAULT_FILTER);
}
/**
* Creates a new LDAP URL including the full set of parameters for a search
* request.
*
* @param isSecured
* {@code true} if this LDAP URL should use LDAPS or {@code false} if
* it should use LDAP.
* @param host
* The name or IP address in dotted format of the LDAP server. For
* example, {@code ldap.server1.com} or {@code 192.202.185.90}. Use
* {@code null} for the local host.
* @param port
* The port number of the LDAP server, or {@code null} to use the
* default port (389 for LDAP and 636 for LDAPS).
* @param name
* The distinguished name of the base entry relative to which the
* search is to be performed, or {@code null} to specify the root
* DSE.
* @param scope
* The search scope, or {@code null} to specify base scope.
* @param filter
* The search filter, or {@code null} to specify the filter {@code
* (objectClass=*)}.
* @param attributes
* The list of attributes to be included in the search results.
* @throws LocalizedIllegalArgumentException
* If {@code port} was less than 1 or greater than 65535.
*/
public LDAPUrl(final boolean isSecured, final String host,
final Integer port, final DN name, final SearchScope scope,
final Filter filter, final String... attributes)
throws LocalizedIllegalArgumentException
{
// The buffer storing the encoded url.
final StringBuilder urlBuffer = new StringBuilder();
// build the scheme.
this.isSecured = isSecured;
if (this.isSecured)
{
urlBuffer.append(SSL_URL_SCHEME);
}
else
{
urlBuffer.append(DEFAULT_URL_SCHEME);
}
urlBuffer.append("://");
if (host == null)
{
this.host = DEFAULT_HOST;
}
else
{
this.host = host;
urlBuffer.append(this.host);
}
int listenPort = DEFAULT_PORT;
if (port == null)
{
listenPort = isSecured ? DEFAULT_SSL_PORT : DEFAULT_PORT;
}
else
{
listenPort = port.intValue();
if (listenPort < 1 || listenPort > 65535)
{
final LocalizableMessage msg = ERR_LDAPURL_BAD_PORT.get(listenPort);
throw new LocalizedIllegalArgumentException(msg);
}
urlBuffer.append(COLON_CHAR);
urlBuffer.append(listenPort);
}
this.port = listenPort;
// We need a slash irrespective of dn is defined or not.
urlBuffer.append(SLASH_CHAR);
if (name != null)
{
this.name = name;
percentEncoder(name.toString(), urlBuffer);
}
else
{
this.name = DEFAULT_DN;
}
// Add attributes.
urlBuffer.append(QUESTION_CHAR);
switch (attributes.length)
{
case 0:
this.attributes = Collections.emptyList();
break;
case 1:
this.attributes = Collections.singletonList(attributes[0]);
urlBuffer.append(attributes[0]);
break;
default:
this.attributes = Collections.unmodifiableList(Arrays.asList(attributes));
urlBuffer.append(attributes[0]);
for (int i = 1; i < attributes.length; i++)
{
urlBuffer.append(COMMA_CHAR);
urlBuffer.append(attributes[i]);
}
break;
}
// Add the scope.
urlBuffer.append(QUESTION_CHAR);
if (scope != null)
{
this.scope = scope;
urlBuffer.append(scope);
}
else
{
this.scope = DEFAULT_SCOPE;
}
// Add the search filter.
urlBuffer.append(QUESTION_CHAR);
if (filter != null)
{
this.filter = filter;
urlBuffer.append(this.filter);
}
else
{
this.filter = DEFAULT_FILTER;
}
urlString = urlBuffer.toString();
}
private LDAPUrl(final String urlString, final Schema schema)
throws LocalizedIllegalArgumentException
{
this.urlString = urlString;
// Parse the url and build the LDAP URL.
final int schemeIdx = urlString.indexOf("://");
if (schemeIdx < 0)
{
final LocalizableMessage msg = ERR_LDAPURL_NO_SCHEME.get(urlString);
throw new LocalizedIllegalArgumentException(msg);
}
final String scheme = StaticUtils.toLowerCase(urlString.substring(0,
schemeIdx));
if (scheme.equalsIgnoreCase(DEFAULT_URL_SCHEME))
{
// Default ldap scheme.
isSecured = false;
}
else if (scheme.equalsIgnoreCase(SSL_URL_SCHEME))
{
isSecured = true;
}
else
{
final LocalizableMessage msg = ERR_LDAPURL_BAD_SCHEME.get(urlString,
scheme);
throw new LocalizedIllegalArgumentException(msg);
}
final int urlLength = urlString.length();
final int hostPortIdx = urlString.indexOf(SLASH_CHAR, schemeIdx + 3);
final StringBuilder builder = new StringBuilder();
if (hostPortIdx < 0)
{
// We got anything here like the host and port?
if (urlLength > (schemeIdx + 3))
{
final String hostAndPort = urlString
.substring(schemeIdx + 3, urlLength);
port = parseHostPort(urlString, hostAndPort, builder);
host = builder.toString();
builder.setLength(0);
}
else
{
// Nothing else is specified apart from the scheme.
// Use the default settings and return from here.
host = DEFAULT_HOST;
port = isSecured ? DEFAULT_SSL_PORT : DEFAULT_PORT;
}
name = DEFAULT_DN;
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
attributes = Collections.emptyList();
return;
}
final String hostAndPort = urlString.substring(schemeIdx + 3, hostPortIdx);
// assign the host and port.
port = parseHostPort(urlString, hostAndPort, builder);
host = builder.toString();
builder.setLength(0);
// Parse the dn.
DN parsedDN = null;
final int dnIdx = urlString.indexOf(QUESTION_CHAR, hostPortIdx + 1);
if (dnIdx < 0)
{
// Whatever we have here is the dn.
final String dnStr = urlString.substring(hostPortIdx + 1, urlLength);
percentDecoder(urlString, hostPortIdx + 1, dnStr, builder);
try
{
parsedDN = DN.valueOf(builder.toString(), schema);
}
catch (final LocalizedIllegalArgumentException e)
{
final LocalizableMessage msg = ERR_LDAPURL_INVALID_DN.get(urlString, e
.getMessageObject());
throw new LocalizedIllegalArgumentException(msg);
}
builder.setLength(0);
name = parsedDN;
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
attributes = Collections.emptyList();
return;
}
final String dnStr = urlString.substring(hostPortIdx + 1, dnIdx);
if (dnStr.length() == 0)
{
parsedDN = DEFAULT_DN;
}
else
{
percentDecoder(urlString, hostPortIdx + 1, dnStr, builder);
try
{
parsedDN = DN.valueOf(builder.toString(), schema);
}
catch (final LocalizedIllegalArgumentException e)
{
final LocalizableMessage msg = ERR_LDAPURL_INVALID_DN.get(urlString, e
.getMessageObject());
throw new LocalizedIllegalArgumentException(msg);
}
builder.setLength(0);
}
name = parsedDN;
// Find out the attributes.
final int attrIdx = urlString.indexOf(QUESTION_CHAR, dnIdx + 1);
if (attrIdx < 0)
{
attributes = Collections.emptyList();
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
return;
}
else
{
final String attrDesc = urlString.substring(dnIdx + 1, attrIdx);
final StringTokenizer token = new StringTokenizer(attrDesc, String
.valueOf(COMMA_CHAR));
final List parsedAttrs = new ArrayList(token
.countTokens());
while (token.hasMoreElements())
{
parsedAttrs.add(token.nextToken());
}
attributes = Collections.unmodifiableList(parsedAttrs);
}
// Find the scope.
final int scopeIdx = urlString.indexOf(QUESTION_CHAR, attrIdx + 1);
SearchScope parsedScope = SearchScope.BASE_OBJECT;
if (scopeIdx < 0)
{
scope = DEFAULT_SCOPE;
filter = DEFAULT_FILTER;
return;
}
else
{
String scopeDef = urlString.substring(attrIdx + 1, scopeIdx);
scopeDef = StaticUtils.toLowerCase(scopeDef);
for (final SearchScope sscope : SearchScope.values())
{
if (sscope.toString().equals(scopeDef))
{
parsedScope = sscope;
break;
}
}
}
scope = parsedScope;
// Last one is filter.
final String parsedFilter = urlString.substring(scopeIdx + 1, urlLength);
if (parsedFilter.length() > 0)
{
// Clear what we already have.
builder.setLength(0);
percentDecoder(urlString, scopeIdx + 1, parsedFilter, builder);
try
{
this.filter = Filter.valueOf(builder.toString());
}
catch (final LocalizedIllegalArgumentException e)
{
final LocalizableMessage msg = ERR_LDAPURL_INVALID_FILTER.get(
urlString, e.getMessageObject());
throw new LocalizedIllegalArgumentException(msg);
}
}
else
{
this.filter = DEFAULT_FILTER;
}
}
/**
* Creates a new search request containing the parameters of this LDAP URL.
*
* @return A new search request containing the parameters of this LDAP URL.
*/
public SearchRequest asSearchRequest()
{
final SearchRequest request = Requests
.newSearchRequest(name, scope, filter);
for (final String a : attributes)
{
request.addAttribute(a);
}
return request;
}
/**
* {@inheritDoc}
*/
@Override()
public boolean equals(final Object o)
{
if (o == this)
{
return true;
}
else if (o instanceof LDAPUrl)
{
final String s1 = toNormalizedString();
final String s2 = ((LDAPUrl) o).toNormalizedString();
return s1.equals(s2);
}
else
{
return false;
}
}
/**
* Returns an unmodifiable list containing the attributes to be included with
* each entry that matches the search criteria. Attributes that are sub-types
* of listed attributes are implicitly included. If the returned list is empty
* then all user attributes will be included by default.
*
* @return An unmodifiable list containing the attributes to be included with
* each entry that matches the search criteria.
*/
public List getAttributes()
{
return attributes;
}
/**
* Returns the search filter associated with this LDAP URL.
*
* @return The search filter associated with this LDAP URL.
*/
public Filter getFilter()
{
return filter;
}
/**
* Returns the name or IP address in dotted format of the LDAP server
* referenced by this LDAP URL. For example, {@code ldap.server1.com} or
* {@code 192.202.185.90}. Use {@code null} for the local host.
*
* @return A name or IP address in dotted format of the LDAP server referenced
* by this LDAP URL.
*/
public String getHost()
{
return host;
}
/**
* Returns the distinguished name of the base entry relative to which the
* search is to be performed.
*
* @return The distinguished name of the base entry relative to which the
* search is to be performed.
*/
public DN getName()
{
return name;
}
/**
* Returns the port number of the LDAP server referenced by this LDAP URL.
*
* @return The port number of the LDAP server referenced by this LDAP URL.
*/
public int getPort()
{
return port;
}
/**
* Returns the search scope associated with this LDAP URL.
*
* @return The search scope associated with this LDAP URL.
*/
public SearchScope getScope()
{
return scope;
}
/**
* {@inheritDoc}
*/
@Override
public int hashCode()
{
final String s = toNormalizedString();
return s.hashCode();
}
/**
* Returns {@code true} if this LDAP URL should use LDAPS or {@code false} if
* it should use LDAP.
*
* @return {@code true} if this LDAP URL should use LDAPS or {@code false} if
* it should use LDAP.
*/
public boolean isSecure()
{
return isSecured;
}
/**
* {@inheritDoc}
*/
@Override
public String toString()
{
return urlString;
}
private int parseHostPort(final String urlString, final String hostAndPort,
final StringBuilder host) throws LocalizedIllegalArgumentException
{
Validator.ensureNotNull(hostAndPort, port, host);
int urlPort = isSecured ? DEFAULT_SSL_PORT : DEFAULT_PORT;
if (hostAndPort.length() == 0)
{
host.append(DEFAULT_HOST);
return urlPort;
}
final int colonIdx = hostAndPort.indexOf(':');
if (colonIdx < 0)
{
// port is not specified.
host.append(hostAndPort);
return urlPort;
}
else
{
String s = hostAndPort.substring(0, colonIdx);
if (s.length() == 0)
{
// Use the default host as we allow only the port to be specified.
host.append(DEFAULT_HOST);
}
else
{
host.append(s);
}
s = hostAndPort.substring(colonIdx + 1, hostAndPort.length());
try
{
urlPort = Integer.parseInt(s);
}
catch (final NumberFormatException e)
{
final LocalizableMessage msg = ERR_LDAPURL_CANNOT_DECODE_PORT.get(
urlString, s);
throw new LocalizedIllegalArgumentException(msg);
}
// Check the validity of the port.
if (urlPort < 1 || urlPort > 65535)
{
final LocalizableMessage msg = ERR_LDAPURL_INVALID_PORT.get(urlString,
urlPort);
throw new LocalizedIllegalArgumentException(msg);
}
}
return urlPort;
}
private String toNormalizedString()
{
if (normalizedURL == null)
{
final StringBuilder builder = new StringBuilder();
if (this.isSecured)
{
builder.append(SSL_URL_SCHEME);
}
else
{
builder.append(DEFAULT_URL_SCHEME);
}
builder.append("://");
builder.append(host);
builder.append(COLON_CHAR);
builder.append(port);
builder.append(SLASH_CHAR);
percentEncoder(name.toString(), builder);
builder.append(QUESTION_CHAR);
final int sz = attributes.size();
for (int i = 0; i < sz; i++)
{
if (i > 0)
{
builder.append(COMMA_CHAR);
}
builder.append(attributes.get(i));
}
builder.append(QUESTION_CHAR);
builder.append(scope);
builder.append(QUESTION_CHAR);
percentEncoder(filter.toString(), builder);
normalizedURL = builder.toString();
}
return normalizedURL;
}
}