///|
/// An LDAP URL (RFC 4516): scheme://host:port/base?attrs?scope?filter?exts.
pub struct LdapUrl {
secure : Bool
host : String
port : Int
base_dn : String
attributes : Array[String]
scope : SearchScope
filter : String
} derive(Eq, @debug.Debug)
///|
pub fn LdapUrl::is_secure(self : LdapUrl) -> Bool {
self.secure
}
///|
fn percent_decode(s : String) -> String raise LdapError {
let bytes = @utf8.encode(s)
let out : Array[Byte] = []
let n = bytes.length()
let mut i = 0
while i < n {
let b = bytes[i]
if b == b'%' {
if i + 2 >= n + 1 || i + 2 > n - 1 + 1 {
// need two hex digits after '%'
if i + 2 >= n + 1 {
raise LdapError::Decode("truncated percent escape in URL")
}
}
if i + 2 > n - 1 {
raise LdapError::Decode("truncated percent escape in URL")
}
let hi = hex_digit(bytes[i + 1])
let lo = hex_digit(bytes[i + 2])
match (hi, lo) {
(Some(h), Some(l)) => {
out.push((h * 16 + l).to_byte())
i = i + 3
}
_ => raise LdapError::Decode("invalid percent escape in URL")
}
} else {
out.push(b)
i = i + 1
}
}
@utf8.decode_lossy(Bytes::from_array(out)[:])
}
///|
fn hex_digit(b : Byte) -> Int? {
let c = b.to_char()
if c >= '0' && c <= '9' {
Some(c.to_int() - '0'.to_int())
} else if c >= 'a' && c <= 'f' {
Some(c.to_int() - 'a'.to_int() + 10)
} else if c >= 'A' && c <= 'F' {
Some(c.to_int() - 'A'.to_int() + 10)
} else {
None
}
}
///|
fn find_char(s : String, target : Char) -> Int? {
for i, ch in s {
if ch == target {
return Some(i)
}
}
None
}
///|
fn earliest_char(s : String, targets : Array[Char]) -> Int? {
let mut best : Int? = None
for t in targets {
match find_char(s, t) {
Some(i) =>
match best {
Some(b) => if i < b { best = Some(i) }
None => best = Some(i)
}
None => ()
}
}
best
}
///|
fn take_chars(s : String, n : Int) -> String {
let buf = StringBuilder()
for i, ch in s {
if i >= n {
break
}
buf.write_char(ch)
}
buf.to_string()
}
///|
fn skip_chars(s : String, n : Int) -> String {
let buf = StringBuilder()
for i, ch in s {
if i < n {
continue
}
buf.write_char(ch)
}
buf.to_string()
}
///|
fn split_all(s : String, sep : Char) -> Array[String] {
let out : Array[String] = []
let mut rest = s
while true {
match find_char(rest, sep) {
Some(i) => {
out.push(take_chars(rest, i))
rest = skip_chars(rest, i + 1)
}
None => {
out.push(rest)
break
}
}
}
out
}
///|
/// Parse an LDAP URL string per RFC 4516.
pub fn parse_ldap_url(url : String) -> Result[LdapUrl, LdapError] {
result_of_ldap(fn() raise LdapError {
let mut rest = url
let mut secure = false
if rest.has_prefix("ldap://") {
rest = skip_chars(rest, 7)
} else if rest.has_prefix("ldaps://") {
rest = skip_chars(rest, 8)
secure = true
} else {
raise LdapError::Decode("URL must start with ldap:// or ldaps://")
}
// authority ends at the first '/' or '?' (RFC 4516).
let stop = earliest_char(rest, ['/', '?'])
let authority = match stop {
Some(i) => take_chars(rest, i)
None => rest
}
let path = match stop {
Some(i) => skip_chars(rest, i + 1)
None => ""
}
if authority.is_empty() {
raise LdapError::Decode("LDAP URL has no host")
}
// host[:port], with optional [ipv6] brackets.
let mut host = authority
let mut port_str = ""
if authority.has_prefix("[") {
let close = match find_char(authority, ']') {
Some(i) => i
None => raise LdapError::Decode("unclosed IPv6 bracket in URL")
}
host = take_chars(authority, close + 1)
let tail = skip_chars(authority, close + 1)
if !tail.is_empty() {
if !tail.has_prefix(":") {
raise LdapError::Decode("invalid characters after IPv6 address")
}
port_str = skip_chars(tail, 1)
}
} else {
match find_char(authority, ':') {
Some(i) => {
host = take_chars(authority, i)
port_str = skip_chars(authority, i + 1)
}
None => ()
}
}
let default_port = if secure { 636 } else { 389 }
let port = if port_str.is_empty() {
default_port
} else {
let port_value = @string.parse_int(port_str) catch {
_ => raise LdapError::Decode("invalid port in URL")
}
if port_value > 0 && port_value < 65536 {
port_value
} else {
raise LdapError::Decode("port out of range in URL")
}
}
// path: base?attrs?scope?filter?exts
let parts = split_all(path, '?')
let base_dn = if parts[0].is_empty() {
""
} else {
percent_decode(parts[0])
}
let attributes : Array[String] = []
if parts.length() > 1 && !parts[1].is_empty() {
let attrs_raw = percent_decode(parts[1])
for attr in split_all(attrs_raw, ',') {
if !attr.is_empty() {
attributes.push(attr)
}
}
}
let scope = if parts.length() > 2 && !parts[2].is_empty() {
match parts[2] {
"base" => Base
"one" => OneLevel
"sub" => Subtree
_ => raise LdapError::Decode("invalid scope in LDAP URL")
}
} else {
Base
}
let filter = if parts.length() > 3 && !parts[3].is_empty() {
percent_decode(parts[3])
} else {
"(objectClass=*)"
}
{ secure, host, port, base_dn, attributes, scope, filter }
})
}
///|
/// Serialize back to a canonical LDAP URL string.
pub fn LdapUrl::to_string(self : LdapUrl) -> String {
let scheme = if self.secure { "ldaps" } else { "ldap" }
let buf = StringBuilder()
buf.write_string("\{scheme}://\{self.host}")
let default_port = if self.secure { 636 } else { 389 }
if self.port != default_port {
buf.write_string(":\{self.port}")
}
if !self.base_dn.is_empty() {
buf.write_string("/\{self.base_dn}")
}
if self.attributes.length() > 0 ||
self.scope != Base ||
self.filter != "(objectClass=*)" {
buf.write_string("?")
let mut first = true
for attr in self.attributes {
if !first {
buf.write_string(",")
}
buf.write_string(attr)
first = false
}
buf.write_string("?\{scope_to_str(self.scope)}")
buf.write_string("?\{self.filter}")
}
buf.to_string()
}
///|
fn scope_to_str(scope : SearchScope) -> String {
match scope {
Base => "base"
OneLevel => "one"
Subtree => "sub"
}
}