Skip to main content

krabka_authz/
host_format.rs

1//! Renders an [`IpAddr`] the way the JDK's `InetAddress.getHostAddress()`
2//! renders it, because that is the text Kafka compares ACL hosts against.
3//!
4//! Rust's [`std::fmt::Display`] impl for [`Ipv6Addr`] and [`IpAddr`] favors
5//! the RFC 5952 compressed form (`::1`), and keeps an IPv4-mapped IPv6 peer
6//! (`::ffff:10.0.0.5`) as an IPv6 address. The JDK does neither:
7//!
8//! - `Inet6Address.getHostAddress()` writes all eight 16-bit groups in lower
9//!   hex, colon-separated, with no leading zeros dropped from the group count
10//!   and no `::` zero-compression, e.g. loopback is `0:0:0:0:0:0:0:1`.
11//! - The socket layer hands the JDK an `Inet4Address` for an IPv4-mapped IPv6
12//!   peer, so `getHostAddress()` on that peer is the dotted-quad form, e.g.
13//!   `10.0.0.5`, not `::ffff:10.0.0.5`.
14//!
15//! [`jdk_host_address`] reproduces both: [`IpAddr::to_canonical`] performs the
16//! IPv4-mapped unwrap, then a genuine IPv6 address is rendered group by group.
17//! [`jdk_host_address_is`] compares that text against an ACL host without
18//! building it, for the per-request authorization path.
19
20use std::{
21    fmt::{self, Write},
22    net::IpAddr,
23};
24
25/// Formats `ip` the way `InetAddress.getHostAddress()` would, so it can be
26/// compared against an ACL host string written by Kafka tooling.
27#[must_use]
28pub fn jdk_host_address(ip: IpAddr) -> String {
29    let mut out = String::with_capacity(39);
30    let _ = write_jdk_host_address(ip, &mut out);
31    out
32}
33
34/// True when [`jdk_host_address`] of `ip` equals `text`, decided without
35/// allocating: the rendering is streamed into a comparator that consumes
36/// `text` piece by piece.
37pub fn jdk_host_address_is(ip: IpAddr, text: &str) -> bool {
38    let mut rest = PrefixMatcher { rest: text };
39    write_jdk_host_address(ip, &mut rest).is_ok() && rest.rest.is_empty()
40}
41
42/// IPv4 in dotted-quad form; a genuine IPv6 address as eight lower-hex groups,
43/// colon-separated, with no zero-compression -- the form
44/// `Inet6Address.getHostAddress()` uses.
45fn write_jdk_host_address(ip: IpAddr, out: &mut impl Write) -> fmt::Result {
46    match ip.to_canonical() {
47        IpAddr::V4(v4) => write!(out, "{v4}"),
48        IpAddr::V6(v6) => {
49            for (i, segment) in v6.segments().iter().enumerate() {
50                if i > 0 {
51                    out.write_char(':')?;
52                }
53                write!(out, "{segment:x}")?;
54            }
55            Ok(())
56        }
57    }
58}
59
60/// A [`Write`] sink that fails as soon as the written text stops being a
61/// prefix of `rest`, and otherwise leaves the unmatched remainder in `rest`.
62struct PrefixMatcher<'a> {
63    rest: &'a str,
64}
65
66impl Write for PrefixMatcher<'_> {
67    fn write_str(&mut self, s: &str) -> fmt::Result {
68        self.rest = self.rest.strip_prefix(s).ok_or(fmt::Error)?;
69        Ok(())
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use {
76        super::{jdk_host_address, jdk_host_address_is},
77        proptest::prelude::*,
78        std::net::{IpAddr, Ipv4Addr, Ipv6Addr},
79    };
80
81    #[yare::parameterized(
82        ipv4_localhost = { IpAddr::V4(Ipv4Addr::LOCALHOST), "127.0.0.1" },
83        ipv4_other = { IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), "10.0.0.5" },
84        ipv6_localhost_expanded = { IpAddr::V6(Ipv6Addr::LOCALHOST), "0:0:0:0:0:0:0:1" },
85        ipv6_unspecified_expanded = { IpAddr::V6(Ipv6Addr::UNSPECIFIED), "0:0:0:0:0:0:0:0" },
86        ipv6_groups_expanded = {
87            IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 5)),
88            "2001:db8:0:0:0:0:0:5",
89        },
90        ipv4_mapped_ipv6 = {
91            IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0005)),
92            "10.0.0.5",
93        },
94    )]
95    fn matches_jdk_text_form(ip: IpAddr, expected: &str) {
96        assert2::assert!(jdk_host_address(ip) == expected);
97        assert2::assert!(jdk_host_address_is(ip, expected));
98    }
99
100    /// The streaming comparison rejects a text that is a strict prefix of the
101    /// rendering, one that extends it, and Rust's compressed IPv6 form.
102    #[yare::parameterized(
103        truncated = { IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), "10.0.0." },
104        extended = { IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), "10.0.0.50" },
105        compressed_ipv6 = { IpAddr::V6(Ipv6Addr::LOCALHOST), "::1" },
106        mapped_ipv6_text = {
107            IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0005)),
108            "::ffff:10.0.0.5",
109        },
110        empty = { IpAddr::V4(Ipv4Addr::LOCALHOST), "" },
111        wildcard = { IpAddr::V4(Ipv4Addr::LOCALHOST), "*" },
112    )]
113    fn rejects_other_text(ip: IpAddr, text: &str) {
114        assert2::assert!(!jdk_host_address_is(ip, text));
115    }
116
117    proptest! {
118        /// The streaming comparison agrees with comparing against the built
119        /// string, for the rendering itself and for any other text.
120        #[test]
121        fn streaming_comparison_agrees_with_rendering(
122            ip in any::<IpAddr>(),
123            other in "[0-9a-f:.]{0,40}",
124        ) {
125            let rendered = jdk_host_address(ip);
126            assert2::assert!(jdk_host_address_is(ip, &rendered));
127            assert2::assert!(jdk_host_address_is(ip, &other) == (other == rendered));
128        }
129    }
130}