diff --git a/src/dynamic/stubs/ldap_ber.rs b/src/dynamic/stubs/ldap_ber.rs new file mode 100644 index 00000000..ae464013 --- /dev/null +++ b/src/dynamic/stubs/ldap_ber.rs @@ -0,0 +1,706 @@ +//! Minimal BER (ASN.1) reader/writer for LDAPv3 bind + search messages. +//! +//! The Phase 06 LDAP stub at [`super::ldap_server`] speaks a custom +//! plaintext `SEARCH \n` / `COUNT \n` framed-line protocol so +//! per-language harnesses can drive it without linking a real LDAP +//! client. The deferred work for that phase tracks "tier (b)" — a +//! real LDAPv3 ASN.1 BER wire round-trip so a harness using +//! `javax.naming.directory.InitialDirContext` (or any other stock LDAP +//! client) can talk to the stub directly. +//! +//! This module is the unblocking primitive: a zero-dependency BER +//! reader+writer that covers exactly the tags LDAPv3 bind + +//! search-request + search-result-entry + search-result-done messages +//! need. It deliberately rejects everything else so a malformed +//! payload cannot exfiltrate state through the parser; rejection falls +//! through to `None` and the caller short-circuits to the plaintext +//! fallback path. +//! +//! # Scope +//! +//! Universal tags: `INTEGER` (0x02), `OCTET STRING` (0x04), +//! `ENUMERATED` (0x0A), `SEQUENCE` (0x30). +//! +//! Application tags (LDAP RFC 4511 §4.1): +//! `BindRequest` (0x60), `BindResponse` (0x61), `SearchRequest` (0x63), +//! `SearchResultEntry` (0x64), `SearchResultDone` (0x65). +//! +//! Context-specific tags inside `Filter` (RFC 4511 §4.5.1): +//! and [0], or [1], not [2], equalityMatch [3], substrings [4], +//! greaterOrEqual [5], lessOrEqual [6], present [7], approxMatch [8]. +//! Plus simple-auth [0] inside `AuthenticationChoice`. +//! +//! Length encoding: short-form (single byte 0x00-0x7F) and long-form +//! (0x81-0x84 length-of-length, value up to 32 bits). Indefinite +//! length (0x80) is rejected — LDAP DER never uses it. +//! +//! Integer encoding: two's-complement, big-endian, minimum-byte form +//! (LDAP integers are non-negative `MessageID` / version / result-code +//! values, but the decoder accepts the full two's-complement range so +//! a hand-rolled client that emits leading zero bytes still parses). +//! +//! # Filter rendering +//! +//! The decoded `SearchRequest` filter is re-rendered into the +//! RFC 4515 string syntax (`(uid=alice)`, `(|(uid=alice)(uid=*))`) so +//! the existing [`super::ldap_server::LdapStub::evaluate`] subset +//! matcher consumes it without a parallel evaluator. Only the four +//! filter shapes the matcher already covers are rendered; anything +//! richer (`>=`, `<=`, `~=`, `not`) collapses to `*` so an exotic +//! adversarial payload over-matches rather than zero-matches. + +#![cfg(feature = "dynamic")] + +/// LDAPv3 BER tag bytes the stub recognises. +pub mod tags { + /// Universal primitive integer (RFC 4511 §5). + pub const INTEGER: u8 = 0x02; + /// Universal primitive octet string. + pub const OCTET_STRING: u8 = 0x04; + /// Universal primitive enumerated. + pub const ENUMERATED: u8 = 0x0A; + /// Universal constructed sequence. + pub const SEQUENCE: u8 = 0x30; + + /// `BindRequest` `[APPLICATION 0]` constructed (RFC 4511 §4.2). + pub const BIND_REQUEST: u8 = 0x60; + /// `BindResponse` `[APPLICATION 1]` constructed. + pub const BIND_RESPONSE: u8 = 0x61; + /// `SearchRequest` `[APPLICATION 3]` constructed. + pub const SEARCH_REQUEST: u8 = 0x63; + /// `SearchResultEntry` `[APPLICATION 4]` constructed. + pub const SEARCH_RESULT_ENTRY: u8 = 0x64; + /// `SearchResultDone` `[APPLICATION 5]` constructed. + pub const SEARCH_RESULT_DONE: u8 = 0x65; + + /// `simple` `[0]` primitive OCTET STRING inside + /// `AuthenticationChoice`. + pub const AUTH_SIMPLE: u8 = 0x80; + + /// Filter `and` `[0]` constructed SET. + pub const FILTER_AND: u8 = 0xA0; + /// Filter `or` `[1]` constructed SET. + pub const FILTER_OR: u8 = 0xA1; + /// Filter `not` `[2]` constructed wrapper. + pub const FILTER_NOT: u8 = 0xA2; + /// Filter `equalityMatch` `[3]` constructed + /// `AttributeValueAssertion`. + pub const FILTER_EQUALITY: u8 = 0xA3; + /// Filter `substrings` `[4]` constructed. + pub const FILTER_SUBSTRINGS: u8 = 0xA4; + /// Filter `present` `[7]` primitive `AttributeDescription`. + pub const FILTER_PRESENT: u8 = 0x87; + + /// Substring `initial` `[0]` primitive. + pub const SUBSTR_INITIAL: u8 = 0x80; + /// Substring `any` `[1]` primitive. + pub const SUBSTR_ANY: u8 = 0x81; + /// Substring `final` `[2]` primitive. + pub const SUBSTR_FINAL: u8 = 0x82; +} + +/// Decoded TLV view. `body` is borrowed from the source buffer; the +/// caller never has to allocate during parsing. +#[derive(Debug, Clone, Copy)] +pub struct Tlv<'a> { + /// Raw tag byte. Match against [`tags`] constants. + pub tag: u8, + /// The value-octets slice (length-prefix already stripped). + pub body: &'a [u8], + /// Offset into the source buffer immediately after this TLV. + pub end: usize, +} + +/// Read a single TLV starting at `offset` in `buf`. Returns `None` +/// when the buffer is too short, the length is indefinite (0x80), or +/// the long-form length-of-length exceeds 4 bytes (>4 GiB messages are +/// out of scope for the in-process stub). +pub fn read_tlv(buf: &[u8], offset: usize) -> Option> { + if offset >= buf.len() { + return None; + } + let tag = buf[offset]; + let first_len = *buf.get(offset + 1)?; + let (length, length_consumed) = if first_len & 0x80 == 0 { + (first_len as usize, 1usize) + } else { + let length_of_length = (first_len & 0x7F) as usize; + if length_of_length == 0 || length_of_length > 4 { + // 0x80 is indefinite length; >4 bytes is too long for the + // in-process stub. + return None; + } + let len_start = offset + 2; + let len_end = len_start + length_of_length; + if len_end > buf.len() { + return None; + } + let mut acc: usize = 0; + for &b in &buf[len_start..len_end] { + acc = (acc << 8) | (b as usize); + } + (acc, 1 + length_of_length) + }; + let body_start = offset + 1 + length_consumed; + let body_end = body_start.checked_add(length)?; + if body_end > buf.len() { + return None; + } + Some(Tlv { + tag, + body: &buf[body_start..body_end], + end: body_end, + }) +} + +/// Decode an `INTEGER` value-octets slice into an `i64`. Rejects +/// inputs longer than 8 bytes — LDAP versions, message IDs, and result +/// codes all fit in 32 bits. +pub fn decode_integer(body: &[u8]) -> Option { + if body.is_empty() || body.len() > 8 { + return None; + } + let sign_extend: i64 = if body[0] & 0x80 != 0 { -1 } else { 0 }; + let mut acc: i64 = sign_extend; + for &b in body { + acc = (acc << 8) | (b as i64 & 0xFF); + } + Some(acc) +} + +/// Append an `INTEGER` TLV to `out`. Minimum-byte two's-complement +/// encoding. +pub fn write_integer(out: &mut Vec, n: i64) { + let mut bytes = n.to_be_bytes().to_vec(); + while bytes.len() > 1 + && ((bytes[0] == 0x00 && bytes[1] & 0x80 == 0) + || (bytes[0] == 0xFF && bytes[1] & 0x80 != 0)) + { + bytes.remove(0); + } + write_tlv(out, tags::INTEGER, &bytes); +} + +/// Append an `ENUMERATED` TLV to `out`. Single-byte encoding (LDAP +/// scope / result-code values all fit in one byte). +pub fn write_enumerated(out: &mut Vec, n: u8) { + write_tlv(out, tags::ENUMERATED, &[n]); +} + +/// Append an `OCTET STRING` TLV to `out`. +pub fn write_octet_string(out: &mut Vec, s: &[u8]) { + write_tlv(out, tags::OCTET_STRING, s); +} + +/// Append a TLV with arbitrary tag + body to `out`. Encodes length in +/// short-form when `body.len() < 128`; long-form otherwise. +pub fn write_tlv(out: &mut Vec, tag: u8, body: &[u8]) { + out.push(tag); + write_length(out, body.len()); + out.extend_from_slice(body); +} + +fn write_length(out: &mut Vec, len: usize) { + if len < 0x80 { + out.push(len as u8); + return; + } + let mut bytes: Vec = Vec::with_capacity(5); + let mut n = len; + while n != 0 { + bytes.push((n & 0xFF) as u8); + n >>= 8; + } + bytes.reverse(); + out.push(0x80 | bytes.len() as u8); + out.extend_from_slice(&bytes); +} + +/// Wrap `body` as a `SEQUENCE` TLV. Convenience helper for assembling +/// LDAP messages. +pub fn wrap_sequence(body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(body.len() + 4); + write_tlv(&mut out, tags::SEQUENCE, body); + out +} + +/// Decoded LDAPMessage header — protocol operation TLV's tag plus +/// body, ready to dispatch on. +#[derive(Debug, Clone, Copy)] +pub struct LdapMessageHeader<'a> { + /// The LDAP message ID the client picked. Echoed verbatim on the + /// matching response. + pub message_id: i64, + /// The protocol op application tag (e.g. [`tags::BIND_REQUEST`]). + pub op_tag: u8, + /// The protocol op value-octets. Pass into [`decode_bind_request`] + /// / [`decode_search_request`] depending on `op_tag`. + pub op_body: &'a [u8], +} + +/// Decode an LDAP message header. The outer `SEQUENCE` must already +/// be the top-level TLV in `buf`. Returns `None` for malformed input, +/// missing fields, or unrecognised protocol-op classes. +pub fn decode_ldap_message(buf: &[u8]) -> Option> { + let outer = read_tlv(buf, 0)?; + if outer.tag != tags::SEQUENCE { + return None; + } + let msg_id_tlv = read_tlv(outer.body, 0)?; + if msg_id_tlv.tag != tags::INTEGER { + return None; + } + let message_id = decode_integer(msg_id_tlv.body)?; + let op_tlv = read_tlv(outer.body, msg_id_tlv.end)?; + Some(LdapMessageHeader { + message_id, + op_tag: op_tlv.tag, + op_body: op_tlv.body, + }) +} + +/// Decoded `BindRequest` (RFC 4511 §4.2). +#[derive(Debug, Clone)] +pub struct BindRequest<'a> { + /// Protocol version (always 3 for LDAPv3). + pub version: i64, + /// The bind DN ("" for anonymous bind). + pub name: &'a [u8], + /// `simple` authentication credential bytes, if present. Other + /// `AuthenticationChoice` variants (SASL) collapse to `None`. + pub simple_password: Option<&'a [u8]>, +} + +/// Decode the value-octets of a `BindRequest`. +pub fn decode_bind_request(body: &[u8]) -> Option> { + let version_tlv = read_tlv(body, 0)?; + if version_tlv.tag != tags::INTEGER { + return None; + } + let version = decode_integer(version_tlv.body)?; + let name_tlv = read_tlv(body, version_tlv.end)?; + if name_tlv.tag != tags::OCTET_STRING { + return None; + } + let auth_tlv = read_tlv(body, name_tlv.end)?; + let simple_password = if auth_tlv.tag == tags::AUTH_SIMPLE { + Some(auth_tlv.body) + } else { + None + }; + Some(BindRequest { + version, + name: name_tlv.body, + simple_password, + }) +} + +/// Decoded `SearchRequest` (RFC 4511 §4.5.1). +#[derive(Debug, Clone)] +pub struct SearchRequest<'a> { + /// Base object DN the search is anchored at. + pub base_object: &'a [u8], + /// Scope enum value (0=baseObject, 1=singleLevel, 2=wholeSubtree). + pub scope: u8, + /// Filter rendered into the RFC 4515 string subset the existing + /// [`super::ldap_server::LdapStub::evaluate`] matcher consumes. + pub filter: String, +} + +/// Decode the value-octets of a `SearchRequest`. +pub fn decode_search_request(body: &[u8]) -> Option> { + let base_tlv = read_tlv(body, 0)?; + if base_tlv.tag != tags::OCTET_STRING { + return None; + } + let scope_tlv = read_tlv(body, base_tlv.end)?; + if scope_tlv.tag != tags::ENUMERATED || scope_tlv.body.len() != 1 { + return None; + } + let scope = scope_tlv.body[0]; + let deref_tlv = read_tlv(body, scope_tlv.end)?; + let size_tlv = read_tlv(body, deref_tlv.end)?; + let time_tlv = read_tlv(body, size_tlv.end)?; + let typesonly_tlv = read_tlv(body, time_tlv.end)?; + let filter_tlv = read_tlv(body, typesonly_tlv.end)?; + let filter = render_filter(filter_tlv.tag, filter_tlv.body); + Some(SearchRequest { + base_object: base_tlv.body, + scope, + filter, + }) +} + +/// Render a decoded filter TLV into the RFC 4515 subset +/// [`super::ldap_server::LdapStub::evaluate`] accepts. Unrecognised +/// shapes collapse to bare `*` so adversarial payloads over-match. +pub fn render_filter(tag: u8, body: &[u8]) -> String { + match tag { + tags::FILTER_AND => render_set("&", body), + tags::FILTER_OR => render_set("|", body), + tags::FILTER_EQUALITY => render_equality(body), + tags::FILTER_PRESENT => { + let attr = String::from_utf8_lossy(body); + format!("({attr}=*)") + } + tags::FILTER_SUBSTRINGS => render_substrings(body), + _ => "*".to_string(), + } +} + +fn render_set(operator: &str, body: &[u8]) -> String { + let mut out = String::from("("); + out.push_str(operator); + let mut cur = 0usize; + while cur < body.len() { + let Some(child) = read_tlv(body, cur) else { + // Truncated SET — break out and let the outer caller fall + // through to over-match. + out.push('*'); + break; + }; + out.push_str(&render_filter(child.tag, child.body)); + cur = child.end; + } + out.push(')'); + out +} + +fn render_equality(body: &[u8]) -> String { + let Some(attr_tlv) = read_tlv(body, 0) else { + return "*".to_string(); + }; + if attr_tlv.tag != tags::OCTET_STRING { + return "*".to_string(); + } + let Some(value_tlv) = read_tlv(body, attr_tlv.end) else { + return "*".to_string(); + }; + if value_tlv.tag != tags::OCTET_STRING { + return "*".to_string(); + } + let attr = String::from_utf8_lossy(attr_tlv.body); + let value = String::from_utf8_lossy(value_tlv.body); + format!("({attr}={value})") +} + +fn render_substrings(body: &[u8]) -> String { + let Some(attr_tlv) = read_tlv(body, 0) else { + return "*".to_string(); + }; + if attr_tlv.tag != tags::OCTET_STRING { + return "*".to_string(); + } + let Some(seq_tlv) = read_tlv(body, attr_tlv.end) else { + return "*".to_string(); + }; + if seq_tlv.tag != tags::SEQUENCE { + return "*".to_string(); + } + let attr = String::from_utf8_lossy(attr_tlv.body); + let mut initial = String::new(); + let mut any_parts: Vec = Vec::new(); + let mut tail = String::new(); + let mut cur = 0usize; + while cur < seq_tlv.body.len() { + let Some(piece) = read_tlv(seq_tlv.body, cur) else { + break; + }; + let text = String::from_utf8_lossy(piece.body).into_owned(); + match piece.tag { + tags::SUBSTR_INITIAL => initial = text, + tags::SUBSTR_ANY => any_parts.push(text), + tags::SUBSTR_FINAL => tail = text, + _ => {} + } + cur = piece.end; + } + let mut joined = initial; + if !any_parts.is_empty() { + joined.push('*'); + joined.push_str(&any_parts.join("*")); + } + joined.push('*'); + joined.push_str(&tail); + format!("({attr}={joined})") +} + +/// Encode a complete `LDAPMessage` carrying `op_tag` + `op_body` as +/// the protocol op. Wraps everything in the outer `SEQUENCE`. +pub fn encode_ldap_message(message_id: i64, op_tag: u8, op_body: &[u8]) -> Vec { + let mut inner = Vec::with_capacity(op_body.len() + 8); + write_integer(&mut inner, message_id); + write_tlv(&mut inner, op_tag, op_body); + wrap_sequence(&inner) +} + +/// LDAP result codes the stub uses (RFC 4511 §4.1.9). +pub mod result_codes { + /// Operation completed successfully. + pub const SUCCESS: u8 = 0; + /// Operation rejected — used here for unrecognised request shapes. + pub const UNWILLING_TO_PERFORM: u8 = 53; +} + +/// Encode a minimal `BindResponse` (success, empty matchedDN, empty +/// diagnosticMessage). +pub fn encode_bind_response(message_id: i64, result_code: u8) -> Vec { + let mut body = Vec::with_capacity(8); + write_enumerated(&mut body, result_code); + write_octet_string(&mut body, b""); + write_octet_string(&mut body, b""); + encode_ldap_message(message_id, tags::BIND_RESPONSE, &body) +} + +/// Encode a `SearchResultEntry` carrying `dn` with no attributes. The +/// Phase 06 LDAP stub's directory model only ever publishes the DN — +/// callers that need attributes can extend this once a fixture surfaces +/// the need. +pub fn encode_search_result_entry(message_id: i64, dn: &[u8]) -> Vec { + let mut body = Vec::with_capacity(dn.len() + 8); + write_octet_string(&mut body, dn); + // PartialAttributeList ::= SEQUENCE OF partial Attribute — empty. + write_tlv(&mut body, tags::SEQUENCE, &[]); + encode_ldap_message(message_id, tags::SEARCH_RESULT_ENTRY, &body) +} + +/// Encode a `SearchResultDone` (RFC 4511 §4.5.2). +pub fn encode_search_result_done(message_id: i64, result_code: u8) -> Vec { + let mut body = Vec::with_capacity(8); + write_enumerated(&mut body, result_code); + write_octet_string(&mut body, b""); + write_octet_string(&mut body, b""); + encode_ldap_message(message_id, tags::SEARCH_RESULT_DONE, &body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_tlv_short_form_length() { + // tag=0x04, len=0x03, body="abc" + let buf = b"\x04\x03abc"; + let tlv = read_tlv(buf, 0).expect("tlv"); + assert_eq!(tlv.tag, 0x04); + assert_eq!(tlv.body, b"abc"); + assert_eq!(tlv.end, 5); + } + + #[test] + fn read_tlv_long_form_length() { + // 200-byte body → length 0x81 0xC8 + let mut buf = vec![0x04, 0x81, 200]; + buf.extend(std::iter::repeat_n(b'a', 200)); + let tlv = read_tlv(&buf, 0).expect("tlv"); + assert_eq!(tlv.body.len(), 200); + } + + #[test] + fn read_tlv_rejects_indefinite_length() { + let buf = [0x30u8, 0x80, 0x00, 0x00]; + assert!(read_tlv(&buf, 0).is_none()); + } + + #[test] + fn read_tlv_rejects_truncated_body() { + let buf = [0x04u8, 0x05, b'a', b'b']; + assert!(read_tlv(&buf, 0).is_none()); + } + + #[test] + fn decode_integer_handles_single_byte() { + assert_eq!(decode_integer(&[3]), Some(3)); + assert_eq!(decode_integer(&[0]), Some(0)); + } + + #[test] + fn decode_integer_handles_negative_via_sign_extension() { + // 0xFF is -1 in two's complement + assert_eq!(decode_integer(&[0xFF]), Some(-1)); + } + + #[test] + fn decode_integer_rejects_empty_and_oversized() { + assert!(decode_integer(&[]).is_none()); + assert!(decode_integer(&[0u8; 9]).is_none()); + } + + #[test] + fn write_integer_minimum_byte_form() { + let mut out = Vec::new(); + write_integer(&mut out, 0); + assert_eq!(out, vec![0x02, 0x01, 0x00]); + + let mut out = Vec::new(); + write_integer(&mut out, 127); + assert_eq!(out, vec![0x02, 0x01, 0x7F]); + + let mut out = Vec::new(); + write_integer(&mut out, 128); + // Need leading zero byte because high bit of 0x80 would make + // the value negative under two's-complement. + assert_eq!(out, vec![0x02, 0x02, 0x00, 0x80]); + } + + #[test] + fn integer_round_trips() { + for n in [0i64, 1, 3, 127, 128, 255, 256, 65535, 65536, -1, -128, -129] { + let mut buf = Vec::new(); + write_integer(&mut buf, n); + let tlv = read_tlv(&buf, 0).expect("tlv"); + assert_eq!(tlv.tag, tags::INTEGER); + assert_eq!(decode_integer(tlv.body), Some(n)); + } + } + + #[test] + fn long_form_length_round_trip() { + let mut buf = Vec::new(); + let body = vec![0xABu8; 1024]; + write_tlv(&mut buf, tags::OCTET_STRING, &body); + let tlv = read_tlv(&buf, 0).expect("tlv"); + assert_eq!(tlv.body, &body[..]); + } + + #[test] + fn bind_request_round_trip() { + // version=3, name="cn=admin", simple_password="secret" + let mut body = Vec::new(); + write_integer(&mut body, 3); + write_octet_string(&mut body, b"cn=admin"); + write_tlv(&mut body, tags::AUTH_SIMPLE, b"secret"); + let msg = encode_ldap_message(/*id=*/ 7, tags::BIND_REQUEST, &body); + let hdr = decode_ldap_message(&msg).expect("header"); + assert_eq!(hdr.message_id, 7); + assert_eq!(hdr.op_tag, tags::BIND_REQUEST); + let req = decode_bind_request(hdr.op_body).expect("bind body"); + assert_eq!(req.version, 3); + assert_eq!(req.name, b"cn=admin"); + assert_eq!(req.simple_password, Some(b"secret".as_slice())); + } + + #[test] + fn bind_response_round_trip_decodes_via_header() { + let msg = encode_bind_response(/*id=*/ 7, result_codes::SUCCESS); + let hdr = decode_ldap_message(&msg).expect("header"); + assert_eq!(hdr.message_id, 7); + assert_eq!(hdr.op_tag, tags::BIND_RESPONSE); + // BindResponse body: ENUMERATED + 2x OCTET STRING + let tlv = read_tlv(hdr.op_body, 0).expect("rc"); + assert_eq!(tlv.tag, tags::ENUMERATED); + assert_eq!(tlv.body, &[0]); + } + + fn build_search_msg(message_id: i64, filter_tag: u8, filter_body: &[u8]) -> Vec { + let mut body = Vec::new(); + write_octet_string(&mut body, b"ou=people,dc=nyx,dc=test"); + write_enumerated(&mut body, 2); // wholeSubtree + write_enumerated(&mut body, 0); // derefAliases neverDerefAliases + write_integer(&mut body, 0); // sizeLimit + write_integer(&mut body, 0); // timeLimit + // typesOnly BOOLEAN false; encoded as 0x01 0x01 0x00 + write_tlv(&mut body, 0x01, &[0x00]); + write_tlv(&mut body, filter_tag, filter_body); + // attributes: empty SEQUENCE + write_tlv(&mut body, tags::SEQUENCE, &[]); + encode_ldap_message(message_id, tags::SEARCH_REQUEST, &body) + } + + fn equality_body(attr: &[u8], value: &[u8]) -> Vec { + let mut body = Vec::new(); + write_octet_string(&mut body, attr); + write_octet_string(&mut body, value); + body + } + + #[test] + fn search_request_equality_filter_renders_to_rfc4515() { + let eq = equality_body(b"uid", b"alice"); + let msg = build_search_msg(11, tags::FILTER_EQUALITY, &eq); + let hdr = decode_ldap_message(&msg).expect("header"); + let req = decode_search_request(hdr.op_body).expect("search"); + assert_eq!(req.base_object, b"ou=people,dc=nyx,dc=test"); + assert_eq!(req.scope, 2); + assert_eq!(req.filter, "(uid=alice)"); + } + + #[test] + fn search_request_present_filter_renders_with_wildcard() { + let msg = build_search_msg(11, tags::FILTER_PRESENT, b"uid"); + let hdr = decode_ldap_message(&msg).expect("header"); + let req = decode_search_request(hdr.op_body).expect("search"); + assert_eq!(req.filter, "(uid=*)"); + } + + #[test] + fn search_request_or_filter_nests_equalities() { + let mut set_body = Vec::new(); + let eq_a = equality_body(b"uid", b"alice"); + let eq_b = equality_body(b"uid", b"bob"); + write_tlv(&mut set_body, tags::FILTER_EQUALITY, &eq_a); + write_tlv(&mut set_body, tags::FILTER_EQUALITY, &eq_b); + let msg = build_search_msg(11, tags::FILTER_OR, &set_body); + let hdr = decode_ldap_message(&msg).expect("header"); + let req = decode_search_request(hdr.op_body).expect("search"); + assert_eq!(req.filter, "(|(uid=alice)(uid=bob))"); + } + + #[test] + fn search_request_and_filter_nests_equalities() { + let mut set_body = Vec::new(); + let eq_a = equality_body(b"uid", b"alice"); + let eq_b = equality_body(b"cn", b"admin"); + write_tlv(&mut set_body, tags::FILTER_EQUALITY, &eq_a); + write_tlv(&mut set_body, tags::FILTER_EQUALITY, &eq_b); + let msg = build_search_msg(11, tags::FILTER_AND, &set_body); + let hdr = decode_ldap_message(&msg).expect("header"); + let req = decode_search_request(hdr.op_body).expect("search"); + assert_eq!(req.filter, "(&(uid=alice)(cn=admin))"); + } + + #[test] + fn search_request_substrings_filter_renders_prefix_star_suffix() { + let mut sub_body = Vec::new(); + write_octet_string(&mut sub_body, b"uid"); + let mut inner = Vec::new(); + write_tlv(&mut inner, tags::SUBSTR_INITIAL, b"al"); + write_tlv(&mut inner, tags::SUBSTR_FINAL, b"ce"); + write_tlv(&mut sub_body, tags::SEQUENCE, &inner); + let msg = build_search_msg(11, tags::FILTER_SUBSTRINGS, &sub_body); + let hdr = decode_ldap_message(&msg).expect("header"); + let req = decode_search_request(hdr.op_body).expect("search"); + assert_eq!(req.filter, "(uid=al*ce)"); + } + + #[test] + fn search_request_unknown_filter_collapses_to_wildcard() { + // 0xA5 = greaterOrEqual — not rendered, falls through to "*". + let body = equality_body(b"uid", b"alice"); + let msg = build_search_msg(11, 0xA5, &body); + let hdr = decode_ldap_message(&msg).expect("header"); + let req = decode_search_request(hdr.op_body).expect("search"); + assert_eq!(req.filter, "*"); + } + + #[test] + fn encode_search_result_entry_round_trip() { + let msg = encode_search_result_entry(/*id=*/ 11, b"uid=alice,ou=people"); + let hdr = decode_ldap_message(&msg).expect("header"); + assert_eq!(hdr.message_id, 11); + assert_eq!(hdr.op_tag, tags::SEARCH_RESULT_ENTRY); + let dn_tlv = read_tlv(hdr.op_body, 0).expect("dn"); + assert_eq!(dn_tlv.tag, tags::OCTET_STRING); + assert_eq!(dn_tlv.body, b"uid=alice,ou=people"); + } + + #[test] + fn encode_search_result_done_round_trip() { + let msg = encode_search_result_done(/*id=*/ 11, result_codes::SUCCESS); + let hdr = decode_ldap_message(&msg).expect("header"); + assert_eq!(hdr.op_tag, tags::SEARCH_RESULT_DONE); + let rc = read_tlv(hdr.op_body, 0).expect("rc"); + assert_eq!(rc.tag, tags::ENUMERATED); + assert_eq!(rc.body, &[0]); + } +} diff --git a/src/dynamic/stubs/ldap_server.rs b/src/dynamic/stubs/ldap_server.rs index 223bb09c..77bc5ed1 100644 --- a/src/dynamic/stubs/ldap_server.rs +++ b/src/dynamic/stubs/ldap_server.rs @@ -10,6 +10,24 @@ //! Endpoint: `127.0.0.1:{port}` (no scheme; the harness composes //! `ldap://` itself if it wants). //! +//! # BER (LDAPv3) dispatch +//! +//! The accept loop peeks the first byte on each connection. When it +//! sees the universal `SEQUENCE` tag (`0x30`) — the leading byte of +//! every well-formed LDAPv3 [`LDAPMessage`] — it routes the +//! conversation through [`super::ldap_ber`] so a harness using a stock +//! LDAP client (`javax.naming.directory.InitialDirContext`, +//! `python-ldap`, `ldap3`, …) can talk to the stub on the LDAPv3 wire +//! protocol. The plaintext `SEARCH \n` framing remains for +//! every other first-byte value, so the existing tier-(a) harnesses +//! keep round-tripping unchanged. +//! +//! No env var gates this — the dispatch is byte-shape driven so a +//! tier-(a) shim that accidentally emits a leading `0x30` will skip +//! the BER path's failure-mode fallback (the BER decoder bails to +//! `None` on a non-LDAPv3 payload, which closes the connection without +//! corrupting state). +//! //! # Directory state //! //! Three users are provisioned at startup: `alice`, `bob`, `carol`. An @@ -41,9 +59,10 @@ //! Signals the accept thread to shut down and connects to itself to //! wake the blocking `accept()`. +use super::ldap_ber; use super::{StubEvent, StubKind, StubProvider, monotonic_ns}; use std::collections::BTreeMap; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -167,11 +186,32 @@ fn accept_loop( } } -fn handle_connection(mut stream: TcpStream, max_bytes: usize, events: &Arc>>) { - let mut reader = match stream.try_clone() { - Ok(s) => BufReader::new(s), +fn handle_connection(stream: TcpStream, max_bytes: usize, events: &Arc>>) { + let reader_stream = match stream.try_clone() { + Ok(s) => s, Err(_) => return, }; + let mut reader = BufReader::new(reader_stream); + // Peek the first byte to decide between the plaintext and BER + // protocol paths. `fill_buf` does not consume — the chosen + // handler reads from `reader` again. + let first_byte = match reader.fill_buf() { + Ok(buf) if !buf.is_empty() => buf[0], + _ => return, + }; + if first_byte == ldap_ber::tags::SEQUENCE { + handle_ber_connection(reader, stream, max_bytes, events); + } else { + handle_plaintext_connection(reader, stream, max_bytes, events); + } +} + +fn handle_plaintext_connection( + mut reader: BufReader, + mut stream: TcpStream, + max_bytes: usize, + events: &Arc>>, +) { let mut line = String::new(); match reader.read_line(&mut line) { Ok(0) => return, @@ -212,6 +252,118 @@ fn handle_connection(mut stream: TcpStream, max_bytes: usize, events: &Arc, + mut stream: TcpStream, + max_bytes: usize, + events: &Arc>>, +) { + loop { + let Some(msg) = read_ber_message(&mut reader, max_bytes) else { + return; + }; + let Some(hdr) = ldap_ber::decode_ldap_message(&msg) else { + return; + }; + match hdr.op_tag { + ldap_ber::tags::BIND_REQUEST => { + // Anonymous + simple binds both succeed — the stub + // does not enforce credentials. + let reply = + ldap_ber::encode_bind_response(hdr.message_id, ldap_ber::result_codes::SUCCESS); + if stream.write_all(&reply).is_err() { + return; + } + } + ldap_ber::tags::SEARCH_REQUEST => { + let Some(req) = ldap_ber::decode_search_request(hdr.op_body) else { + let done = ldap_ber::encode_search_result_done( + hdr.message_id, + ldap_ber::result_codes::UNWILLING_TO_PERFORM, + ); + let _ = stream.write_all(&done); + return; + }; + let matches = match_filter(&req.filter); + let count = matches.len(); + for uid in &matches { + let dn = format!("uid={uid},ou=people,dc=nyx,dc=test"); + let entry = + ldap_ber::encode_search_result_entry(hdr.message_id, dn.as_bytes()); + if stream.write_all(&entry).is_err() { + return; + } + } + let done = ldap_ber::encode_search_result_done( + hdr.message_id, + ldap_ber::result_codes::SUCCESS, + ); + if stream.write_all(&done).is_err() { + return; + } + let _ = stream.flush(); + let ev = StubEvent { + kind: StubKind::Ldap, + captured_at_ns: monotonic_ns(), + summary: format!("SEARCH {filter}", filter = req.filter), + detail: { + let mut d = BTreeMap::new(); + d.insert("filter".to_owned(), req.filter); + d.insert("protocol".to_owned(), "ldapv3".to_owned()); + d.insert("entries_returned".to_owned(), count.to_string()); + d + }, + }; + if let Ok(mut g) = events.lock() { + g.push(ev); + } + } + _ => { + // Unbind / abandon / extended / etc. — bail. The + // verifier oracle only cares about search results. + return; + } + } + } +} + +/// Read a single LDAPv3 BER `LDAPMessage` off the wire. Parses just +/// enough of the outer TLV to compute the message length, then reads +/// exactly that many body bytes. Returns `None` for malformed +/// framing or when the message size exceeds `max_bytes`. +fn read_ber_message(reader: &mut BufReader, max_bytes: usize) -> Option> { + let mut header = vec![0u8; 2]; + reader.read_exact(&mut header).ok()?; + if header[0] != ldap_ber::tags::SEQUENCE { + return None; + } + let body_len = if header[1] & 0x80 == 0 { + header[1] as usize + } else { + let length_of_length = (header[1] & 0x7F) as usize; + if length_of_length == 0 || length_of_length > 4 { + return None; + } + let mut len_bytes = vec![0u8; length_of_length]; + reader.read_exact(&mut len_bytes).ok()?; + let mut acc: usize = 0; + for &b in &len_bytes { + acc = (acc << 8) | (b as usize); + } + header.extend_from_slice(&len_bytes); + acc + }; + if header.len() + body_len > max_bytes { + return None; + } + let mut body = vec![0u8; body_len]; + reader.read_exact(&mut body).ok()?; + header.extend_from_slice(&body); + Some(header) +} + /// RFC-4515-subset matcher. See module docs for the grammar. fn match_filter(filter: &str) -> Vec<&'static str> { let trimmed = filter.trim(); @@ -453,4 +605,133 @@ mod tests { std::thread::sleep(Duration::from_millis(50)); let _ = TcpListener::bind(format!("127.0.0.1:{port}")); } + + fn build_ber_bind(message_id: i64) -> Vec { + let mut body = Vec::new(); + ldap_ber::write_integer(&mut body, 3); + ldap_ber::write_octet_string(&mut body, b""); + ldap_ber::write_tlv(&mut body, ldap_ber::tags::AUTH_SIMPLE, b""); + ldap_ber::encode_ldap_message(message_id, ldap_ber::tags::BIND_REQUEST, &body) + } + + fn build_ber_search(message_id: i64, filter_tag: u8, filter_body: &[u8]) -> Vec { + let mut body = Vec::new(); + ldap_ber::write_octet_string(&mut body, b"ou=people,dc=nyx,dc=test"); + ldap_ber::write_enumerated(&mut body, 2); + ldap_ber::write_enumerated(&mut body, 0); + ldap_ber::write_integer(&mut body, 0); + ldap_ber::write_integer(&mut body, 0); + ldap_ber::write_tlv(&mut body, 0x01, &[0x00]); + ldap_ber::write_tlv(&mut body, filter_tag, filter_body); + ldap_ber::write_tlv(&mut body, ldap_ber::tags::SEQUENCE, &[]); + ldap_ber::encode_ldap_message(message_id, ldap_ber::tags::SEARCH_REQUEST, &body) + } + + fn read_ber_reply(stream: &mut TcpStream) -> Vec { + let mut buf = Vec::new(); + // Read until the peer closes (the BER handler stays open + // until the client disconnects). A short read timeout was + // configured at accept time, so a stuck reader would unblock + // there anyway. + let _ = stream.read_to_end(&mut buf); + buf + } + + #[test] + fn ber_bind_then_search_wildcard_returns_three_entries() { + let stub = LdapStub::start().unwrap(); + let mut s = TcpStream::connect(format!("127.0.0.1:{}", stub.port())).unwrap(); + let bind = build_ber_bind(1); + s.write_all(&bind).unwrap(); + let search = build_ber_search(2, ldap_ber::tags::FILTER_PRESENT, b"uid"); + s.write_all(&search).unwrap(); + s.shutdown(std::net::Shutdown::Write).unwrap(); + let reply = read_ber_reply(&mut s); + // Walk the reply: BindResponse (msg id 1, tag 0x61), then + // 3x SearchResultEntry (tag 0x64), then SearchResultDone + // (tag 0x65). + let bind_resp = ldap_ber::read_tlv(&reply, 0).expect("bind tlv"); + assert_eq!(bind_resp.tag, ldap_ber::tags::SEQUENCE); + let bind_hdr = ldap_ber::decode_ldap_message(&reply[..bind_resp.end]).expect("bind hdr"); + assert_eq!(bind_hdr.op_tag, ldap_ber::tags::BIND_RESPONSE); + assert_eq!(bind_hdr.message_id, 1); + + let mut cur = bind_resp.end; + let mut entries: usize = 0; + let mut saw_done = false; + while cur < reply.len() { + let tlv = ldap_ber::read_tlv(&reply, cur).expect("tlv"); + assert_eq!(tlv.tag, ldap_ber::tags::SEQUENCE); + let hdr = ldap_ber::decode_ldap_message(&reply[cur..tlv.end]).expect("hdr"); + match hdr.op_tag { + ldap_ber::tags::SEARCH_RESULT_ENTRY => entries += 1, + ldap_ber::tags::SEARCH_RESULT_DONE => { + saw_done = true; + break; + } + _ => panic!("unexpected op tag {:#x}", hdr.op_tag), + } + cur = tlv.end; + } + assert_eq!(entries, 3); + assert!(saw_done); + std::thread::sleep(Duration::from_millis(20)); + let events = stub.drain_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].detail.get("entries_returned").map(String::as_str), + Some("3"), + ); + assert_eq!( + events[0].detail.get("protocol").map(String::as_str), + Some("ldapv3"), + ); + } + + #[test] + fn ber_search_concrete_uid_returns_one_entry() { + let stub = LdapStub::start().unwrap(); + let mut s = TcpStream::connect(format!("127.0.0.1:{}", stub.port())).unwrap(); + s.write_all(&build_ber_bind(1)).unwrap(); + let mut eq_body = Vec::new(); + ldap_ber::write_octet_string(&mut eq_body, b"uid"); + ldap_ber::write_octet_string(&mut eq_body, b"alice"); + s.write_all(&build_ber_search(2, ldap_ber::tags::FILTER_EQUALITY, &eq_body)) + .unwrap(); + s.shutdown(std::net::Shutdown::Write).unwrap(); + let reply = read_ber_reply(&mut s); + // Skip past the BindResponse. + let bind_resp = ldap_ber::read_tlv(&reply, 0).expect("bind tlv"); + let mut cur = bind_resp.end; + let mut entry_dns: Vec = Vec::new(); + let mut saw_done = false; + while cur < reply.len() { + let tlv = ldap_ber::read_tlv(&reply, cur).expect("tlv"); + let hdr = ldap_ber::decode_ldap_message(&reply[cur..tlv.end]).expect("hdr"); + if hdr.op_tag == ldap_ber::tags::SEARCH_RESULT_ENTRY { + let dn_tlv = ldap_ber::read_tlv(hdr.op_body, 0).expect("dn"); + entry_dns.push(String::from_utf8_lossy(dn_tlv.body).into_owned()); + } else if hdr.op_tag == ldap_ber::tags::SEARCH_RESULT_DONE { + saw_done = true; + break; + } + cur = tlv.end; + } + assert_eq!(entry_dns, vec!["uid=alice,ou=people,dc=nyx,dc=test"]); + assert!(saw_done); + } + + #[test] + fn plaintext_path_still_works_after_ber_branch_added() { + // Same shape as `search_request_returns_three_for_wildcard_via_socket` + // but the leading byte is `S` (0x53), not `0x30`, so the + // accept-loop dispatches plaintext. + let stub = LdapStub::start().unwrap(); + let mut s = TcpStream::connect(format!("127.0.0.1:{}", stub.port())).unwrap(); + s.write_all(b"SEARCH (uid=*)\n").unwrap(); + s.flush().unwrap(); + let mut out = String::new(); + s.read_to_string(&mut out).unwrap(); + assert!(out.starts_with("COUNT 3\n"), "got {out:?}"); + } } diff --git a/src/dynamic/stubs/mod.rs b/src/dynamic/stubs/mod.rs index 6267f603..a88cbe1b 100644 --- a/src/dynamic/stubs/mod.rs +++ b/src/dynamic/stubs/mod.rs @@ -58,6 +58,7 @@ pub mod broker_rabbit; pub mod broker_sqs; pub mod filesystem; pub mod http; +pub mod ldap_ber; pub mod ldap_server; pub mod mocks; pub mod redis;