[pitboss/grind] deferred session-0007 (20260522T043516Z-29b8)

This commit is contained in:
pitboss 2026-05-22 02:24:44 -05:00
parent ed63433c61
commit e2940fc3cc
3 changed files with 636 additions and 57 deletions

View file

@ -782,12 +782,20 @@ echo json_encode(["entity_expanded" => $expanded]) . "\n";
/// Phase 06 — Track J.4 LDAP-injection harness for PHP (`ldap_search`).
///
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter,
/// evaluates the filter against the in-sandbox LDAP directory (three
/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
/// count the directory returned. Mirrors the synthetic-harness
/// pattern used by Phase 03 / 04 / 05.
/// and — when `NYX_LDAP_ENDPOINT` is set — routes the search through
/// the in-sandbox LDAP stub over the real LDAPv3 BER wire (the stub's
/// accept loop at [`crate::dynamic::stubs::ldap_server::accept_loop`]
/// auto-detects the `0x30 SEQUENCE` lead byte and routes through the
/// reader/writer at [`crate::dynamic::stubs::ldap_ber`]). Falls back
/// to an in-process RFC 4515 subset matcher against three canonical
/// users (`alice`, `bob`, `carol`) when the env var is unset, the
/// filter does not parse as a supported RFC 4515 shape, or the socket
/// exchange errors, so the harness still produces a verdict on hosts
/// that exercise it outside the stub-backed corpus. Writes a
/// `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
/// count the directory returned. The BER client is core-PHP only
/// (`fsockopen` / `fwrite` / `fread`) so no `ext-ldap` extension is
/// required.
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
let shim = probe_shim();
let body = format!(
@ -866,7 +874,235 @@ function _nyx_match_one(string $filt, string $uid): bool {{
return _nyx_attr_match($pattern, $uid);
}}
function _nyx_ldap_count_via_stub(string $filt): ?int {{
// --- LDAPv3 BER client (zero-dep, core PHP only) -------------------------
// Tags this client emits / consumes. Mirrors `src/dynamic/stubs/ldap_ber.rs`.
const _NYX_BER_BOOLEAN = 0x01;
const _NYX_BER_INTEGER = 0x02;
const _NYX_BER_OCTET_STRING = 0x04;
const _NYX_BER_ENUMERATED = 0x0A;
const _NYX_BER_SEQUENCE = 0x30;
const _NYX_BER_BIND_REQUEST = 0x60;
const _NYX_BER_BIND_RESPONSE = 0x61;
const _NYX_BER_SEARCH_REQUEST = 0x63;
const _NYX_BER_SEARCH_RESULT_ENTRY = 0x64;
const _NYX_BER_SEARCH_RESULT_DONE = 0x65;
const _NYX_BER_AUTH_SIMPLE = 0x80;
const _NYX_BER_FILTER_AND = 0xA0;
const _NYX_BER_FILTER_OR = 0xA1;
const _NYX_BER_FILTER_EQUALITY = 0xA3;
const _NYX_BER_FILTER_SUBSTRINGS = 0xA4;
const _NYX_BER_FILTER_PRESENT = 0x87;
const _NYX_BER_SUBSTR_INITIAL = 0x80;
const _NYX_BER_SUBSTR_ANY = 0x81;
const _NYX_BER_SUBSTR_FINAL = 0x82;
function _nyx_ber_length(int $n): string {{
if ($n < 0x80) return chr($n);
$tmp = '';
while ($n > 0) {{
$tmp = chr($n & 0xFF) . $tmp;
$n >>= 8;
}}
return chr(0x80 | strlen($tmp)) . $tmp;
}}
function _nyx_ber_tlv(int $tag, string $body): string {{
return chr($tag) . _nyx_ber_length(strlen($body)) . $body;
}}
function _nyx_ber_int(int $n): ?string {{
if ($n < 0) return null;
if ($n === 0) {{
$body = "\x00";
}} else {{
$tmp = '';
while ($n > 0) {{
$tmp = chr($n & 0xFF) . $tmp;
$n >>= 8;
}}
if (ord($tmp[0]) & 0x80) {{
$tmp = "\x00" . $tmp;
}}
$body = $tmp;
}}
return _nyx_ber_tlv(_NYX_BER_INTEGER, $body);
}}
function _nyx_ber_enum(int $n): string {{
return _nyx_ber_tlv(_NYX_BER_ENUMERATED, chr($n & 0xFF));
}}
function _nyx_ber_octstr(string $s): string {{
return _nyx_ber_tlv(_NYX_BER_OCTET_STRING, $s);
}}
function _nyx_ber_bool(bool $b): string {{
return _nyx_ber_tlv(_NYX_BER_BOOLEAN, $b ? "\xFF" : "\x00");
}}
function _nyx_ber_seq(string $body): string {{
return _nyx_ber_tlv(_NYX_BER_SEQUENCE, $body);
}}
function _nyx_valid_attr(string $a): bool {{
if ($a === '') return false;
$n = strlen($a);
for ($i = 0; $i < $n; $i++) {{
$c = $a[$i];
if (!(ctype_alnum($c) || $c === '-' || $c === '_' || $c === '.')) return false;
}}
return true;
}}
function _nyx_split_paren_children(string $s): ?array {{
$out = [];
$i = 0;
$n = strlen($s);
while ($i < $n) {{
if ($s[$i] !== '(') return null;
$depth = 0;
$start = $i;
while ($i < $n) {{
$c = $s[$i];
if ($c === '(') $depth++;
elseif ($c === ')') {{
$depth--;
if ($depth === 0) {{ $i++; break; }}
}}
$i++;
}}
if ($depth !== 0) return null;
$out[] = substr($s, $start, $i - $start);
}}
return $out;
}}
function _nyx_encode_filter(string $filt): ?string {{
$s = trim($filt);
if (!str_starts_with($s, '(') || !str_ends_with($s, ')')) return null;
$depth = 0;
$n = strlen($s);
for ($i = 0; $i < $n; $i++) {{
$c = $s[$i];
if ($c === '(') $depth++;
elseif ($c === ')') {{
$depth--;
if ($depth < 0) return null;
if ($depth === 0 && $i !== $n - 1) return null;
}}
}}
if ($depth !== 0) return null;
$inner = substr($s, 1, strlen($s) - 2);
if ($inner === '') return null;
$head = $inner[0];
if ($head === '&' || $head === '|') {{
$children = _nyx_split_paren_children(substr($inner, 1));
if ($children === null || empty($children)) return null;
$parts = '';
foreach ($children as $c) {{
$sub = _nyx_encode_filter($c);
if ($sub === null) return null;
$parts .= $sub;
}}
$tag = $head === '&' ? _NYX_BER_FILTER_AND : _NYX_BER_FILTER_OR;
return _nyx_ber_tlv($tag, $parts);
}}
$eq = strpos($inner, '=');
if ($eq === false) return null;
$attr = substr($inner, 0, $eq);
$val = substr($inner, $eq + 1);
if (!_nyx_valid_attr($attr)) return null;
if ($val === '*') {{
return _nyx_ber_tlv(_NYX_BER_FILTER_PRESENT, $attr);
}}
if (strpos($val, '*') !== false) {{
$parts = explode('*', $val);
$last = count($parts) - 1;
$seq = '';
if ($parts[0] !== '') {{
$seq .= _nyx_ber_tlv(_NYX_BER_SUBSTR_INITIAL, $parts[0]);
}}
for ($i = 1; $i < $last; $i++) {{
if ($parts[$i] !== '') {{
$seq .= _nyx_ber_tlv(_NYX_BER_SUBSTR_ANY, $parts[$i]);
}}
}}
if ($parts[$last] !== '') {{
$seq .= _nyx_ber_tlv(_NYX_BER_SUBSTR_FINAL, $parts[$last]);
}}
$body = _nyx_ber_octstr($attr) . _nyx_ber_seq($seq);
return _nyx_ber_tlv(_NYX_BER_FILTER_SUBSTRINGS, $body);
}}
$body = _nyx_ber_octstr($attr) . _nyx_ber_octstr($val);
return _nyx_ber_tlv(_NYX_BER_FILTER_EQUALITY, $body);
}}
function _nyx_read_n($sock, int $n): ?string {{
$out = '';
while (strlen($out) < $n) {{
$chunk = @fread($sock, $n - strlen($out));
if ($chunk === false || $chunk === '') return null;
$out .= $chunk;
}}
return $out;
}}
function _nyx_read_ber_message($sock): ?string {{
$head = _nyx_read_n($sock, 2);
if ($head === null || ord($head[0]) !== _NYX_BER_SEQUENCE) return null;
$first_len = ord($head[1]);
if (($first_len & 0x80) === 0) {{
$body_len = $first_len;
$length_bytes = '';
}} else {{
$nl = $first_len & 0x7F;
if ($nl === 0 || $nl > 4) return null;
$length_bytes = _nyx_read_n($sock, $nl);
if ($length_bytes === null) return null;
$body_len = 0;
for ($i = 0; $i < $nl; $i++) {{
$body_len = ($body_len << 8) | ord($length_bytes[$i]);
}}
}}
if ($body_len > 64 * 1024) return null;
$body = _nyx_read_n($sock, $body_len);
if ($body === null) return null;
return $head . $length_bytes . $body;
}}
function _nyx_decode_tlv(string $buf, int $offset): ?array {{
if ($offset + 2 > strlen($buf)) return null;
$tag = ord($buf[$offset]);
$first_len = ord($buf[$offset + 1]);
if (($first_len & 0x80) === 0) {{
$body_len = $first_len;
$body_start = $offset + 2;
}} else {{
$nl = $first_len & 0x7F;
if ($nl === 0 || $nl > 4 || $offset + 2 + $nl > strlen($buf)) return null;
$body_len = 0;
for ($i = 0; $i < $nl; $i++) {{
$body_len = ($body_len << 8) | ord($buf[$offset + 2 + $i]);
}}
$body_start = $offset + 2 + $nl;
}}
$body_end = $body_start + $body_len;
if ($body_end > strlen($buf)) return null;
return [$tag, substr($buf, $body_start, $body_len), $body_end];
}}
function _nyx_decode_ldap_op(string $msg): ?array {{
$outer = _nyx_decode_tlv($msg, 0);
if ($outer === null || $outer[0] !== _NYX_BER_SEQUENCE) return null;
$inner = $outer[1];
$msg_id_tlv = _nyx_decode_tlv($inner, 0);
if ($msg_id_tlv === null || $msg_id_tlv[0] !== _NYX_BER_INTEGER) return null;
$op_tlv = _nyx_decode_tlv($inner, $msg_id_tlv[2]);
if ($op_tlv === null) return null;
return [$op_tlv[0], $op_tlv[1]];
}}
function _nyx_ldap_count_via_ber(string $filt): ?int {{
$ep = getenv('NYX_LDAP_ENDPOINT');
if ($ep === false || $ep === '') return null;
$sep = strrpos($ep, ':');
@ -874,20 +1110,47 @@ function _nyx_ldap_count_via_stub(string $filt): ?int {{
$host = substr($ep, 0, $sep);
$port = (int) substr($ep, $sep + 1);
if ($port <= 0) return null;
$filter_bytes = _nyx_encode_filter($filt);
if ($filter_bytes === null) return null;
$errno = 0;
$errstr = '';
$sock = @fsockopen($host, $port, $errno, $errstr, 2.0);
if ($sock === false) return null;
stream_set_timeout($sock, 2);
@fwrite($sock, 'SEARCH ' . $filt . "\n");
$line = @fgets($sock);
@fclose($sock);
if ($line === false) return null;
$line = rtrim($line, "\r\n");
if (!str_starts_with($line, 'COUNT ')) return null;
$tail = trim(substr($line, strlen('COUNT ')));
if ($tail === '' || !ctype_digit($tail)) return null;
return (int) $tail;
$bind_body = _nyx_ber_int(3) . _nyx_ber_octstr('') . _nyx_ber_tlv(_NYX_BER_AUTH_SIMPLE, '');
$bind_msg = _nyx_ber_seq(_nyx_ber_int(1) . _nyx_ber_tlv(_NYX_BER_BIND_REQUEST, $bind_body));
if (@fwrite($sock, $bind_msg) === false) {{ @fclose($sock); return null; }}
$resp = _nyx_read_ber_message($sock);
if ($resp === null) {{ @fclose($sock); return null; }}
$decoded = _nyx_decode_ldap_op($resp);
if ($decoded === null || $decoded[0] !== _NYX_BER_BIND_RESPONSE) {{ @fclose($sock); return null; }}
$search_body = _nyx_ber_octstr('')
. _nyx_ber_enum(2)
. _nyx_ber_enum(0)
. _nyx_ber_int(0)
. _nyx_ber_int(2)
. _nyx_ber_bool(false)
. $filter_bytes
. _nyx_ber_seq('');
$search_msg = _nyx_ber_seq(_nyx_ber_int(2) . _nyx_ber_tlv(_NYX_BER_SEARCH_REQUEST, $search_body));
if (@fwrite($sock, $search_msg) === false) {{ @fclose($sock); return null; }}
$count = 0;
while (true) {{
$resp = _nyx_read_ber_message($sock);
if ($resp === null) {{ @fclose($sock); return null; }}
$decoded = _nyx_decode_ldap_op($resp);
if ($decoded === null) {{ @fclose($sock); return null; }}
$op_tag = $decoded[0];
if ($op_tag === _NYX_BER_SEARCH_RESULT_ENTRY) {{
$count++;
}} elseif ($op_tag === _NYX_BER_SEARCH_RESULT_DONE) {{
@fclose($sock);
return $count;
}} else {{
@fclose($sock);
return $count;
}}
}}
}}
function _nyx_ldap_count_local(string $filt, array $users): int {{
@ -904,8 +1167,8 @@ function _nyx_ldap_count_local(string $filt, array $users): int {{
}}
function _nyx_ldap_count(string $filt, array $users): int {{
$via_stub = _nyx_ldap_count_via_stub($filt);
if ($via_stub !== null) return $via_stub;
$via_ber = _nyx_ldap_count_via_ber($filt);
if ($via_ber !== null) return $via_ber;
return _nyx_ldap_count_local($filt, $users);
}}
@ -2164,12 +2427,20 @@ mod tests {
"PHP LDAP harness must open a TCP socket against the stub endpoint",
);
assert!(
h.source.contains("'SEARCH '"),
"PHP LDAP harness must write SEARCH <filter> over the wire",
h.source.contains("_NYX_BER_BIND_REQUEST = 0x60"),
"PHP LDAP harness must compose an LDAPv3 BindRequest (BER tag 0x60)",
);
assert!(
h.source.contains("'COUNT '"),
"PHP LDAP harness must parse the COUNT <n> reply line",
h.source.contains("_NYX_BER_SEARCH_REQUEST = 0x63"),
"PHP LDAP harness must compose an LDAPv3 SearchRequest (BER tag 0x63)",
);
assert!(
h.source.contains("_nyx_encode_filter"),
"PHP LDAP harness must encode the RFC 4515 filter string into BER bytes",
);
assert!(
!h.source.contains("'SEARCH '"),
"PHP LDAP harness must no longer write the plaintext SEARCH <filter> tier-(a) framing",
);
}
@ -2181,8 +2452,8 @@ mod tests {
"PHP LDAP harness must keep the in-process matcher as a fallback for hosts without the stub",
);
assert!(
h.source.contains("_nyx_ldap_count_via_stub"),
"PHP LDAP harness must dispatch through the stub-route helper",
h.source.contains("_nyx_ldap_count_via_ber"),
"PHP LDAP harness must dispatch through the BER stub-route helper",
);
}

View file

@ -1560,12 +1560,19 @@ if __name__ == "__main__":
/// (`ldap.search_s`).
///
/// Reads `NYX_PAYLOAD`, splices it into a `(uid=<payload>)` filter,
/// evaluates the filter against the in-sandbox LDAP directory (three
/// users: `alice`, `bob`, `carol`) using the same RFC-4515 subset the
/// [`crate::dynamic::stubs::ldap_server`] stub implements, and writes
/// a `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
/// count the directory returned. Mirrors the synthetic-harness
/// pattern used by Phase 03 / 04 / 05.
/// and — when `NYX_LDAP_ENDPOINT` is set — routes the search through
/// the in-sandbox LDAP stub over the real LDAPv3 BER wire (the stub's
/// accept loop at [`crate::dynamic::stubs::ldap_server::accept_loop`]
/// auto-detects the `0x30 SEQUENCE` lead byte and routes through the
/// reader/writer at [`crate::dynamic::stubs::ldap_ber`]). Falls back
/// to an in-process RFC 4515 subset matcher against three canonical
/// users (`alice`, `bob`, `carol`) when the env var is unset, the
/// filter does not parse as a supported RFC 4515 shape, or the socket
/// exchange errors, so the harness still produces a verdict on hosts
/// that exercise it outside the stub-backed corpus. Writes a
/// `ProbeKind::Ldap { entries_returned }` probe whose `n` is the
/// count the directory returned. The BER client is pure-stdlib (just
/// `socket`) so no extra pip dep is required.
pub fn emit_ldap_harness(_spec: &HarnessSpec) -> HarnessSource {
let probe = probe_shim();
let body = format!(
@ -1644,12 +1651,250 @@ def _nyx_match_one(filt, uid):
return _nyx_attr_match(pattern, uid)
def _nyx_ldap_count_via_stub(filt):
"""Route through the in-sandbox LDAP stub when NYX_LDAP_ENDPOINT is set.
# --- LDAPv3 BER client (zero-dep, pure stdlib) ----------------------------
# Tags this client emits / consumes. Mirrors `src/dynamic/stubs/ldap_ber.rs`.
_NYX_BER_BOOLEAN = 0x01
_NYX_BER_INTEGER = 0x02
_NYX_BER_OCTET_STRING = 0x04
_NYX_BER_ENUMERATED = 0x0A
_NYX_BER_SEQUENCE = 0x30
_NYX_BER_BIND_REQUEST = 0x60
_NYX_BER_BIND_RESPONSE = 0x61
_NYX_BER_SEARCH_REQUEST = 0x63
_NYX_BER_SEARCH_RESULT_ENTRY = 0x64
_NYX_BER_SEARCH_RESULT_DONE = 0x65
_NYX_BER_AUTH_SIMPLE = 0x80
_NYX_BER_FILTER_AND = 0xA0
_NYX_BER_FILTER_OR = 0xA1
_NYX_BER_FILTER_EQUALITY = 0xA3
_NYX_BER_FILTER_SUBSTRINGS = 0xA4
_NYX_BER_FILTER_PRESENT = 0x87
_NYX_BER_SUBSTR_INITIAL = 0x80
_NYX_BER_SUBSTR_ANY = 0x81
_NYX_BER_SUBSTR_FINAL = 0x82
Returns the parsed `COUNT <n>` reply on success, or ``None`` when the
env var is unset, the address fails to parse, or the socket exchange
errors caller falls back to the in-process matcher.
def _nyx_ber_length(n):
if n < 0x80:
return bytes([n])
tmp = []
while n:
tmp.append(n & 0xFF)
n >>= 8
tmp.reverse()
return bytes([0x80 | len(tmp)]) + bytes(tmp)
def _nyx_ber_tlv(tag, body):
return bytes([tag]) + _nyx_ber_length(len(body)) + body
def _nyx_ber_int(n):
if n < 0:
return None
if n == 0:
body = b"\x00"
else:
tmp = []
x = n
while x > 0:
tmp.append(x & 0xFF)
x >>= 8
tmp.reverse()
body = bytes(tmp)
if body[0] & 0x80:
body = b"\x00" + body
return _nyx_ber_tlv(_NYX_BER_INTEGER, body)
def _nyx_ber_enum(n):
return _nyx_ber_tlv(_NYX_BER_ENUMERATED, bytes([n & 0xFF]))
def _nyx_ber_octstr(s):
if isinstance(s, str):
s = s.encode("utf-8")
return _nyx_ber_tlv(_NYX_BER_OCTET_STRING, s)
def _nyx_ber_bool(b):
return _nyx_ber_tlv(_NYX_BER_BOOLEAN, b"\xFF" if b else b"\x00")
def _nyx_ber_seq(body):
return _nyx_ber_tlv(_NYX_BER_SEQUENCE, body)
def _nyx_valid_attr(a):
if not a:
return False
for ch in a:
if not (ch.isalnum() or ch in "-_."):
return False
return True
def _nyx_split_paren_children(s):
"""Split a string of concatenated `(...)(...)` groups into a list."""
out = []
i = 0
n = len(s)
while i < n:
if s[i] != "(":
return None
depth = 0
start = i
while i < n:
c = s[i]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
i += 1
break
i += 1
if depth != 0:
return None
out.append(s[start:i])
return out
def _nyx_encode_filter(filt):
"""RFC 4515 (subset) -> BER bytes. Returns ``None`` for invalid /
unsupported filter shapes; caller falls back to the local matcher."""
s = filt.strip()
if not s.startswith("(") or not s.endswith(")"):
return None
depth = 0
for i, c in enumerate(s):
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth < 0:
return None
if depth == 0 and i != len(s) - 1:
return None
if depth != 0:
return None
inner = s[1:-1]
if not inner:
return None
head = inner[0]
if head in ("&", "|"):
children = _nyx_split_paren_children(inner[1:])
if not children:
return None
parts = b""
for c in children:
sub = _nyx_encode_filter(c)
if sub is None:
return None
parts += sub
tag = _NYX_BER_FILTER_AND if head == "&" else _NYX_BER_FILTER_OR
return _nyx_ber_tlv(tag, parts)
if "=" not in inner:
return None
attr, _, val = inner.partition("=")
if not _nyx_valid_attr(attr):
return None
if val == "*":
return _nyx_ber_tlv(_NYX_BER_FILTER_PRESENT, attr.encode("utf-8"))
if "*" in val:
parts = val.split("*")
seq = b""
if parts[0]:
seq += _nyx_ber_tlv(_NYX_BER_SUBSTR_INITIAL, parts[0].encode("utf-8"))
for p in parts[1:-1]:
if p:
seq += _nyx_ber_tlv(_NYX_BER_SUBSTR_ANY, p.encode("utf-8"))
if parts[-1]:
seq += _nyx_ber_tlv(_NYX_BER_SUBSTR_FINAL, parts[-1].encode("utf-8"))
body = _nyx_ber_octstr(attr) + _nyx_ber_seq(seq)
return _nyx_ber_tlv(_NYX_BER_FILTER_SUBSTRINGS, body)
body = _nyx_ber_octstr(attr) + _nyx_ber_octstr(val)
return _nyx_ber_tlv(_NYX_BER_FILTER_EQUALITY, body)
def _nyx_read_n(sock, n):
out = b""
while len(out) < n:
chunk = sock.recv(n - len(out))
if not chunk:
return None
out += chunk
return out
def _nyx_read_ber_message(sock):
head = _nyx_read_n(sock, 2)
if head is None or head[0] != _NYX_BER_SEQUENCE:
return None
if head[1] & 0x80 == 0:
body_len = head[1]
length_bytes = b""
else:
nl = head[1] & 0x7F
if nl == 0 or nl > 4:
return None
length_bytes = _nyx_read_n(sock, nl)
if length_bytes is None:
return None
body_len = 0
for b in length_bytes:
body_len = (body_len << 8) | b
if body_len > 64 * 1024:
return None
body = _nyx_read_n(sock, body_len)
if body is None:
return None
return head + length_bytes + body
def _nyx_decode_tlv(buf, offset):
if offset + 2 > len(buf):
return None
tag = buf[offset]
first_len = buf[offset + 1]
if first_len & 0x80 == 0:
body_len = first_len
body_start = offset + 2
else:
nl = first_len & 0x7F
if nl == 0 or nl > 4 or offset + 2 + nl > len(buf):
return None
body_len = 0
for b in buf[offset + 2:offset + 2 + nl]:
body_len = (body_len << 8) | b
body_start = offset + 2 + nl
body_end = body_start + body_len
if body_end > len(buf):
return None
return (tag, buf[body_start:body_end], body_end)
def _nyx_decode_ldap_op(msg):
"""Return ``(op_tag, op_body)`` for an LDAPMessage byte slice."""
outer = _nyx_decode_tlv(msg, 0)
if outer is None or outer[0] != _NYX_BER_SEQUENCE:
return None
inner = outer[1]
msg_id_tlv = _nyx_decode_tlv(inner, 0)
if msg_id_tlv is None or msg_id_tlv[0] != _NYX_BER_INTEGER:
return None
op_tlv = _nyx_decode_tlv(inner, msg_id_tlv[2])
if op_tlv is None:
return None
return (op_tlv[0], op_tlv[1])
def _nyx_ldap_count_via_ber(filt):
"""Route through the in-sandbox LDAP stub via real LDAPv3 BER when
`NYX_LDAP_ENDPOINT` is set. Returns the entry count on success, or
``None`` when the env var is unset, the filter is not a supported
RFC 4515 shape, the address fails to parse, the bind fails, or the
socket exchange errors caller falls back to the in-process matcher.
"""
ep = os.environ.get("NYX_LDAP_ENDPOINT", "")
if not ep:
@ -1662,23 +1907,56 @@ def _nyx_ldap_count_via_stub(filt):
port = int(ep[sep + 1:])
except ValueError:
return None
filter_bytes = _nyx_encode_filter(filt)
if filter_bytes is None:
return None
try:
with socket.create_connection((host, port), timeout=2.0) as sock:
sock.sendall(("SEARCH " + filt + "\n").encode("utf-8"))
buf = sock.makefile("rb")
line = buf.readline()
if not line:
sock.settimeout(2.0)
bind_body = (
_nyx_ber_int(3)
+ _nyx_ber_octstr(b"")
+ _nyx_ber_tlv(_NYX_BER_AUTH_SIMPLE, b"")
)
bind_msg = _nyx_ber_seq(
_nyx_ber_int(1) + _nyx_ber_tlv(_NYX_BER_BIND_REQUEST, bind_body)
)
sock.sendall(bind_msg)
resp = _nyx_read_ber_message(sock)
if resp is None:
return None
try:
line_s = line.decode("utf-8", "replace").rstrip("\r\n")
except Exception:
return None
if not line_s.startswith("COUNT "):
return None
try:
return int(line_s[len("COUNT "):].strip())
except ValueError:
decoded = _nyx_decode_ldap_op(resp)
if decoded is None or decoded[0] != _NYX_BER_BIND_RESPONSE:
return None
search_body = (
_nyx_ber_octstr(b"")
+ _nyx_ber_enum(2)
+ _nyx_ber_enum(0)
+ _nyx_ber_int(0)
+ _nyx_ber_int(2)
+ _nyx_ber_bool(False)
+ filter_bytes
+ _nyx_ber_seq(b"")
)
search_msg = _nyx_ber_seq(
_nyx_ber_int(2) + _nyx_ber_tlv(_NYX_BER_SEARCH_REQUEST, search_body)
)
sock.sendall(search_msg)
count = 0
while True:
resp = _nyx_read_ber_message(sock)
if resp is None:
return None
decoded = _nyx_decode_ldap_op(resp)
if decoded is None:
return None
op_tag = decoded[0]
if op_tag == _NYX_BER_SEARCH_RESULT_ENTRY:
count += 1
elif op_tag == _NYX_BER_SEARCH_RESULT_DONE:
return count
else:
return count
except (OSError, socket.timeout):
return None
@ -1695,9 +1973,9 @@ def _nyx_ldap_count_local(filt):
def _nyx_ldap_count(filt):
via_stub = _nyx_ldap_count_via_stub(filt)
if via_stub is not None:
return via_stub
via_ber = _nyx_ldap_count_via_ber(filt)
if via_ber is not None:
return via_ber
return _nyx_ldap_count_local(filt)
@ -3150,12 +3428,20 @@ mod tests {
"Python LDAP harness must open a TCP socket against the stub endpoint",
);
assert!(
h.source.contains("SEARCH "),
"Python LDAP harness must write SEARCH <filter> over the wire",
h.source.contains("_NYX_BER_BIND_REQUEST = 0x60"),
"Python LDAP harness must compose an LDAPv3 BindRequest (BER tag 0x60)",
);
assert!(
h.source.contains("COUNT "),
"Python LDAP harness must parse the COUNT <n> reply line",
h.source.contains("_NYX_BER_SEARCH_REQUEST = 0x63"),
"Python LDAP harness must compose an LDAPv3 SearchRequest (BER tag 0x63)",
);
assert!(
h.source.contains("_nyx_encode_filter"),
"Python LDAP harness must encode the RFC 4515 filter string into BER bytes",
);
assert!(
!h.source.contains("\"SEARCH \""),
"Python LDAP harness must no longer write the plaintext SEARCH <filter> tier-(a) framing",
);
}
@ -3167,8 +3453,8 @@ mod tests {
"Python LDAP harness must keep the in-process matcher as a fallback for hosts without the stub",
);
assert!(
h.source.contains("_nyx_ldap_count_via_stub"),
"Python LDAP harness must dispatch through the stub-route helper",
h.source.contains("_nyx_ldap_count_via_ber"),
"Python LDAP harness must dispatch through the BER stub-route helper",
);
}