use std::ops::Range;
use super::policy;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Candidate<'a> {
pub(crate) offset: usize,
pub(crate) text: &'a str,
}
fn is_address_byte(byte: u8) -> bool {
byte.is_ascii_hexdigit() || is_separator_byte(byte)
}
fn is_separator_byte(byte: u8) -> bool {
matches!(byte, b'.' | b':' | b'-')
}
fn is_identifier_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
pub(crate) fn candidates(text: &str) -> Vec<Candidate<'_>> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut start = 0;
while start < bytes.len() {
if !is_address_byte(bytes[start]) {
start += 1;
continue;
}
let mut end = start;
while end < bytes.len() && is_address_byte(bytes[end]) {
end += 1;
}
let run = start..end;
start = end;
if let Some(run) = bound(bytes, run) {
start = emit(text, run, &mut out).max(start);
}
}
out
}
fn bound(bytes: &[u8], run: Range<usize>) -> Option<Range<usize>> {
if run.end < bytes.len() && is_identifier_byte(bytes[run.end]) {
return None;
}
if run.start == 0 || !is_identifier_byte(bytes[run.start - 1]) {
return Some(run);
}
let lead_end = (run.start..run.end)
.find(|index| !is_separator_byte(bytes[*index]))
.unwrap_or(run.end);
if lead_end == run.start {
return None;
}
if bytes[run.start..lead_end]
.windows(2)
.any(|pair| pair == b"::")
{
return None;
}
(lead_end < run.end).then_some(lead_end..run.end)
}
fn emit<'a>(text: &'a str, run: Range<usize>, out: &mut Vec<Candidate<'a>>) -> usize {
let end = run.end;
let Some((offset, token)) = trim(text, run.start, &text[run]) else {
return end;
};
if !splits(token) {
if let Some(suffix) = cidr_suffix(text, offset + token.len()) {
let stop = offset + token.len() + suffix;
out.push(Candidate {
offset,
text: &text[offset..stop],
});
return stop;
}
out.push(Candidate {
offset,
text: token,
});
return end;
}
let separator = if token.contains('-') { '-' } else { ':' };
let mut cursor = offset;
for part in token.split(separator) {
if let Some((at, trimmed)) = trim(text, cursor, part) {
out.push(Candidate {
offset: at,
text: trimmed,
});
}
cursor += part.len() + separator.len_utf8();
}
end
}
fn splits(token: &str) -> bool {
if policy::mac_octets(token).is_some() {
return false;
}
if token.contains('-') {
return true;
}
token.contains(':') && !policy::looks_like_ipv6(token)
}
fn trim<'a>(text: &'a str, offset: usize, token: &'a str) -> Option<(usize, &'a str)> {
let trimmed = token.trim_end_matches(['.', '-']);
let trimmed = if trimmed.ends_with("::") {
trimmed
} else {
trimmed.trim_end_matches(':')
};
let lead = trimmed.len() - trimmed.trim_start_matches(['.', '-']).len();
let trimmed = &trimmed[lead..];
if trimmed.is_empty() {
return None;
}
let offset = offset + lead;
debug_assert_eq!(&text[offset..offset + trimmed.len()], trimmed);
Some((offset, trimmed))
}
fn cidr_suffix(text: &str, at: usize) -> Option<usize> {
let bytes = text.as_bytes();
if bytes.get(at) != Some(&b'/') {
return None;
}
let mut end = at + 1;
while end < bytes.len() && bytes[end].is_ascii_digit() && end - at <= 3 {
end += 1;
}
if end == at + 1 {
return None;
}
if bytes.get(end).is_some_and(|byte| is_identifier_byte(*byte)) {
return None;
}
Some(end - at)
}
#[cfg(test)]
mod tests {
use super::*;
fn tokens(text: &str) -> Vec<&str> {
candidates(text)
.into_iter()
.map(|candidate| candidate.text)
.collect()
}
fn offsets(text: &str) -> Vec<usize> {
candidates(text)
.into_iter()
.map(|candidate| candidate.offset)
.collect()
}
fn read(text: &str) -> Vec<&str> {
candidates(text)
.into_iter()
.filter(|candidate| policy::read(candidate.text, policy::Context::default()).is_some())
.map(|candidate| candidate.text)
.collect()
}
#[test]
fn an_address_is_found_with_its_offset() {
assert_eq!(tokens("bind 10.0.0.1 now"), ["10.0.0.1"]);
assert_eq!(offsets("bind 10.0.0.1 now"), [5]);
}
#[test]
fn every_candidate_slices_the_document_it_came_from() {
let text = "q=10.0.0.1:53, w=[2001:db8::1]:80, r=aa-bb-cc-dd-ee-ff, t=10.0.0.0/8\n";
for candidate in candidates(text) {
assert_eq!(
&text[candidate.offset..candidate.offset + candidate.text.len()],
candidate.text
);
}
}
#[test]
fn a_port_is_split_off_its_host() {
assert_eq!(tokens("127.0.0.1:5432"), ["127.0.0.1", "5432"]);
assert_eq!(read("postgres://10.0.0.5:5432/app"), ["10.0.0.5"]);
}
#[test]
fn a_bracketed_ipv6_address_survives_its_brackets_and_port() {
assert_eq!(read("http://[2001:db8::1]:8080/x"), ["2001:db8::1"]);
}
#[test]
fn a_run_glued_to_a_name_is_not_a_candidate() {
for text in [
"use std::net::Ipv6Addr;",
"let x = Vec::<u8>::new();",
"x10.0.0.1",
"10.0.0.1z",
"released v1.2.3.4 today",
] {
assert!(read(text).is_empty(), "{text} produced {:?}", read(text));
}
}
#[test]
fn one_separator_between_a_name_and_a_run_is_punctuation() {
assert_eq!(read("client_ip:10.0.0.1"), ["10.0.0.1"]);
assert_eq!(read("from=10.0.0.1"), ["10.0.0.1"]);
}
#[test]
fn a_path_operator_after_a_name_is_glue_and_one_colon_is_not() {
for text in [
"zzz::1.2.3.4",
"Widget::10.0.0.1",
"std::net::10.0.0.1",
"GROUP:::10.0.0.1",
] {
assert!(read(text).is_empty(), "{text} produced {:?}", read(text));
}
assert_eq!(read("zzz:1.2.3.4"), ["1.2.3.4"]);
assert_eq!(read("upstream:2001:db8::1"), ["2001:db8::1"]);
}
#[test]
fn a_run_that_trims_away_to_nothing_is_not_a_candidate() {
for text in ["...", " -.- ", "-.-.-", "prose. Next."] {
assert!(candidates(text).is_empty(), "{text} -> {:?}", tokens(text));
}
assert_eq!(tokens(" :: "), ["::"], "the unspecified address survives");
}
#[test]
fn timestamps_and_durations_are_not_addresses() {
for text in [
"2026-08-12T10:30:00Z",
"[12/Aug/2026:10:30:00 +0000]",
"took 1.25 ms",
"v1.2.3",
"elapsed 00:00:01.250",
] {
assert!(read(text).is_empty(), "{text} produced {:?}", read(text));
}
}
#[test]
fn a_hyphen_range_is_two_candidates_and_a_mac_is_one() {
assert_eq!(read("10.0.0.1-10.0.0.9"), ["10.0.0.1", "10.0.0.9"]);
assert_eq!(read("aa-bb-cc-dd-ee-ff"), ["aa-bb-cc-dd-ee-ff"]);
assert_eq!(read("aa:bb:cc:dd:ee:ff"), ["aa:bb:cc:dd:ee:ff"]);
}
#[test]
fn a_prefix_is_part_of_the_candidate_and_not_a_second_one() {
assert_eq!(tokens("allow 10.0.0.0/8 here"), ["10.0.0.0/8"]);
assert_eq!(tokens("allow 2001:db8::/32"), ["2001:db8::/32"]);
}
#[test]
fn a_url_path_is_not_a_prefix() {
assert_eq!(read("http://10.0.0.1/api/v2"), ["10.0.0.1"]);
}
#[test]
fn trailing_punctuation_is_not_part_of_an_address() {
assert_eq!(read("reached 10.0.0.1."), ["10.0.0.1"]);
assert_eq!(read("host 10.0.0.1:"), ["10.0.0.1"]);
assert_eq!(read("prefix 2001:db8::"), ["2001:db8::"]);
}
#[test]
fn an_uncompressed_ipv4_embedded_address_stays_whole() {
assert_eq!(
read("saw 0:0:0:0:0:ffff:192.0.2.1 once"),
["0:0:0:0:0:ffff:192.0.2.1"]
);
assert_eq!(
read("saw 2001:db8:0:0:0:0:0:1 once"),
["2001:db8:0:0:0:0:0:1"]
);
}
#[test]
fn an_empty_document_yields_nothing() {
assert!(candidates("").is_empty());
assert!(read("no addresses in this prose").is_empty());
}
#[test]
fn a_multibyte_document_keeps_its_offsets() {
let text = "café 10.0.0.1";
let found = candidates(text);
let last = found.last().expect("a candidate");
assert_eq!(last.text, "10.0.0.1");
assert_eq!(&text[last.offset..], "10.0.0.1");
}
}