Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 187 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@

mod structures;

use crate::structures::{flatten_ranges, Part, RangeOutput};
const CARDINALITY_THRESHOLD: u64 = 100_000;

use crate::structures::{Cardinality, Part, RangeOutput, flatten_ranges};
use combine::{
attempt, between, choice, eof,
Parser, attempt, between, choice, eof,
error::{ParseError, StreamError},
many1, not_followed_by, optional,
parser::{
EasyParser,
char::{alpha_num, digit, spaces},
combinator::ignore,
repeat::repeat_until,
EasyParser,
token::satisfy,
},
sep_by1,
stream::{Stream, StreamErrorFor},
token, Parser,
token,
};
use itertools::Itertools as _;

Expand Down Expand Up @@ -69,6 +72,14 @@ where
many1(alpha_num().or(dash()).or(token('.')))
}

fn host_elements6<I>() -> impl Parser<I, Output = String>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
many1(alpha_num().or(dash()).or(token(':')))
}

fn digits<I>() -> impl Parser<I, Output = String>
where
I: Stream<Token = char>,
Expand All @@ -77,6 +88,14 @@ where
many1(digit())
}

fn hex_digits<I>() -> impl Parser<I, Output = String>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
many1(satisfy(|c: char| c.is_ascii_hexdigit()))
}

fn leading_zeros<I>() -> impl Parser<I, Output = (usize, u64)>
where
I: Stream<Token = char>,
Expand All @@ -95,6 +114,24 @@ where
})
}

fn leading_hex<I>() -> impl Parser<I, Output = (usize, u64)>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
hex_digits().and_then(|x| {
let mut digits = x.chars().take_while(|x| x == &'0').count();

if x.len() == digits {
digits -= 1;
}

u64::from_str_radix(&x, 16)
.map(|num| (digits, num))
.map_err(StreamErrorFor::<I>::other)
})
}

fn range_digits<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
Expand Down Expand Up @@ -135,6 +172,46 @@ where
})
}

fn range_hex<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
attempt((
leading_hex(),
optional_spaces().with(dash()),
optional_spaces().with(leading_hex()),
))
.and_then(|((start_zeros, start), _, (end_zeros, end))| {
let mut xs = [start, end];
xs.sort_unstable();

let same_prefix_len = start_zeros == end_zeros;

let (range, start_zeros, end_zeros) = if start > end {
(
RangeOutput::HexRangeReversed(end_zeros, same_prefix_len, end, start),
end_zeros,
start_zeros,
)
} else {
(
RangeOutput::HexRange(start_zeros, same_prefix_len, start, end),
start_zeros,
end_zeros,
)
};

if end_zeros > start_zeros {
Err(StreamErrorFor::<I>::unexpected_static_message(
"larger end padding",
))
} else {
Ok(range)
}
})
}

fn disjoint_digits<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
Expand All @@ -157,6 +234,28 @@ where
.map(RangeOutput::Disjoint)
}

fn disjoint_hex<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
let not_name = not_followed_by(
optional_spaces()
.with(hex_digits())
.skip(optional_spaces())
.skip(dash())
.map(|_| ""),
);

sep_by1(
optional_spaces()
.with(leading_hex())
.skip(optional_spaces()),
attempt(comma().skip(not_name)),
)
.map(RangeOutput::HexDisjoint)
}

fn range<I>() -> impl Parser<I, Output = Vec<RangeOutput>>
where
I: Stream<Token = char>,
Expand All @@ -169,7 +268,19 @@ where
)
}

fn hostlist<I>() -> impl Parser<I, Output = Vec<Part>>
fn range6<I>() -> impl Parser<I, Output = Vec<RangeOutput>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
between(
open_bracket(),
close_bracket(),
sep_by1(range_hex().or(disjoint_hex()), comma()),
)
}

fn hostlist4<I>() -> impl Parser<I, Output = Vec<Part>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
Expand All @@ -195,12 +306,45 @@ where
})
}

fn hostlist6<I>() -> impl Parser<I, Output = Vec<Part>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
repeat_until(
choice([
range6().map(Part::Range).left(),
optional_spaces()
.with(host_elements6())
.map(Part::String)
.right(),
]),
attempt(optional_spaces().skip(ignore(comma()).or(eof()))),
)
.and_then(|xs: Vec<_>| {
if xs.is_empty() {
Err(StreamErrorFor::<I>::unexpected_static_message(
"no host found",
))
} else if xs.cardinality() > CARDINALITY_THRESHOLD {
Err(StreamErrorFor::<I>::unexpected_static_message(
"cardinality overflow",
))
} else {
Ok(xs)
}
})
}

fn hostlists<I>() -> impl Parser<I, Output = Vec<Vec<Part>>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
sep_by1(hostlist(), optional_spaces().with(comma()))
sep_by1(
choice([hostlist4().left(), hostlist6().right()]),
optional_spaces().with(comma()),
)
}

pub fn parse(input: &str) -> Result<Vec<String>, combine::stream::easy::Errors<char, &str, usize>> {
Expand Down Expand Up @@ -292,9 +436,9 @@ mod tests {

#[test]
fn test_hostlist() {
assert_debug_snapshot!(hostlist().easy_parse("oss1.local"));
assert_debug_snapshot!(hostlist().easy_parse("oss[1,2].local"));
assert_debug_snapshot!(hostlist().easy_parse(
assert_debug_snapshot!(hostlist4().easy_parse("oss1.local"));
assert_debug_snapshot!(hostlist4().easy_parse("oss[1,2].local"));
assert_debug_snapshot!(hostlist4().easy_parse(
"hostname[2,6,7].iml.com,hostname[10,11-12,2-3,5].iml.com,hostname[15-17].iml.com"
));
}
Expand Down Expand Up @@ -379,7 +523,12 @@ mod tests {
)
);

assert_debug_snapshot!("Multiple ranges per hostname in which the difference is 1", parse("hostname[1,2-3].iml[2,3].com,hostname[3,4,5].iml[2,3].com,hostname[5-6,7].iml[2,3].com"));
assert_debug_snapshot!(
"Multiple ranges per hostname in which the difference is 1",
parse(
"hostname[1,2-3].iml[2,3].com,hostname[3,4,5].iml[2,3].com,hostname[5-6,7].iml[2,3].com"
)
);

assert_debug_snapshot!(
"Multiple ranges per hostname in which the difference is 1 two formats",
Expand Down Expand Up @@ -492,4 +641,32 @@ mod tests {
fn test_parse_osts() {
assert_debug_snapshot!("Leading 0s", parse("OST01[00,01]"));
}

#[test]
fn test_parse_ip_addresses() {
assert_debug_snapshot!("IPv4 single", parse("192.168.0.1"));
assert_debug_snapshot!("IPv6 compressed", parse("2001:db8::1"));
assert_debug_snapshot!(
"IPv6 full",
parse("fe80:1234:5678:9abc:def0:1234:5678:9abc")
);
assert_debug_snapshot!("Multiple IPv6 literals", parse("2001:db8::1, 2001:db8::2"));
assert_debug_snapshot!("IPv6 expansion", parse("2001:db8::[0-f]"));
assert_debug_snapshot!("IPv6 expansion with base 16", parse("2001:db8::[00-10]"));
assert_debug_snapshot!(
"IPv6 expansion with multiple ranges",
parse("2001:db[0-8]::[00-10]:1")
);

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert_debug_snapshot!("IPv6 expansion with base 16", parse("2001:db8::[00-10]"));

This should also work. But maybe it makes sense to limit that. Its very easy to generate billions of addresses.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason the hex functions allowed only strings with alphabetic characters ? Removed this and your test works.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on limiting the amount of expansion to 2-3 chars? I'm not sure.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inappropriate usage of these wild cards is always going to be an issue.

Copy link

@spoutn1k spoutn1k Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean we can easily count a range and abort if the amount is greater than a threshold

assert_debug_snapshot!("IPv4 with v6 range", parse("192.168.0.[0-f]").unwrap_err());
}

#[test]
fn test_parse_capacity() {
assert_debug_snapshot!("IPv6 expansion valid", parse("2001:db8::[0000-ffff]"));
assert_debug_snapshot!(
"IPv6 expansion overflow",
parse("2001:db8::[0-f]:[0000-ffff]").unwrap_err()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ Err(
'.',
),
),
Expected(
Token(
':',
),
),
Unexpected(
Token(
'h',
Expand Down
9 changes: 9 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv4 literal.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"192.168.0.1\")"
---
Ok(
[
"192.168.0.1",
],
)
9 changes: 9 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv4 single.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"192.168.0.1\")"
---
Ok(
[
"192.168.0.1",
],
)
24 changes: 24 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv4 with v6 range.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
source: src/lib.rs
expression: "parse(\"192.168.0.[0-f]\").unwrap_err()"
---
Errors {
position: 12,
errors: [
Unexpected(
Token(
'-',
),
),
Expected(
Token(
',',
),
),
Expected(
Token(
']',
),
),
],
}
9 changes: 9 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv6 compressed.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"2001:db8::1\")"
---
Ok(
[
"2001:db8::1",
],
)
14 changes: 14 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv6 expansion overflow.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
source: src/lib.rs
expression: "parse(\"2001:db8::[0000-ffff]:[0000-ffff]\").unwrap_err()"
---
Errors {
position: 0,
errors: [
Unexpected(
Static(
"cardinality overflow",
),
),
],
}
Loading