pub mod detect;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct NodeId(pub usize);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Path(pub Vec<usize>);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Span {
pub line: usize,
pub start_byte: usize,
pub end_byte: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedLineParts {
pub head: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TriviaKind {
Blank,
Comment,
Content,
Unknown,
Literal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineNode {
pub raw: String,
pub line_ending: String,
pub span: Span,
pub parsed: Option<ParsedLineParts>,
pub key_hint: Option<String>,
pub trivia: TriviaKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockNode {
pub header: LineNode,
pub children: Vec<NodeId>,
pub footer: Option<LineNode>,
pub kind_label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Node {
Line(LineNode),
Block(BlockNode),
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DocumentMetadata {
pub source_name: Option<String>,
pub dialect_hint: DialectHint,
pub original_bytes: usize,
pub line_count: usize,
pub parse_findings: Vec<ParseFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DialectHint {
#[default]
Generic,
Unknown,
Named(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParseFinding {
pub code: String,
pub message: String,
pub span: Span,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Document {
pub metadata: DocumentMetadata,
pub roots: Vec<NodeId>,
pub arena: Vec<Node>,
}
impl Document {
pub fn new(metadata: DocumentMetadata) -> Self {
Self {
metadata,
roots: Vec::new(),
arena: Vec::new(),
}
}
pub fn insert_root(&mut self, node: Node) -> NodeId {
let id = self.insert_node(node);
self.roots.push(id);
id
}
pub fn insert_node(&mut self, node: Node) -> NodeId {
let id = NodeId(self.arena.len());
self.arena.push(node);
id
}
pub fn node(&self, id: NodeId) -> Option<&Node> {
self.arena.get(id.0)
}
pub fn add_child(&mut self, parent: NodeId, child: NodeId) -> bool {
match self.arena.get_mut(parent.0) {
Some(Node::Block(block)) => {
block.children.push(child);
true
}
_ => false,
}
}
pub fn render(&self) -> String {
let mut out = String::new();
for root in &self.roots {
self.render_node(*root, &mut out);
}
out
}
fn render_node(&self, id: NodeId, out: &mut String) {
if let Some(node) = self.arena.get(id.0) {
match node {
Node::Line(line) => {
out.push_str(&line.raw);
out.push_str(&line.line_ending);
}
Node::Block(block) => {
out.push_str(&block.header.raw);
out.push_str(&block.header.line_ending);
for child in &block.children {
self.render_node(*child, out);
}
if let Some(footer) = &block.footer {
out.push_str(&footer.raw);
out.push_str(&footer.line_ending);
}
}
}
}
}
}
pub fn parse_generic(input: &str) -> Document {
parse_with_dialect(input, &GenericDialect)
}
pub trait Dialect {
fn dialect_hint(&self) -> DialectHint {
DialectHint::Unknown
}
fn classify_trivia(&self, raw: &str) -> TriviaKind;
fn parse_parts(&self, raw: &str) -> Option<ParsedLineParts>;
fn key_hint(
&self,
_raw: &str,
_parsed: Option<&ParsedLineParts>,
_trivia: TriviaKind,
) -> Option<String> {
None
}
fn block_terminator(&self, _raw: &str) -> bool {
false
}
fn literal_region(&self, _raw: &str) -> Option<LiteralTerminator> {
None
}
fn comment_region(&self, _raw: &str) -> Option<LiteralTerminator> {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LiteralTerminator {
Contains(String),
ExactLine(String),
UnescapedQuote,
}
impl LiteralTerminator {
pub fn terminates(&self, raw: &str) -> bool {
match self {
Self::Contains(pattern) => raw.contains(pattern.as_str()),
Self::ExactLine(pattern) => raw.trim() == pattern,
Self::UnescapedQuote => unescaped_quote_count(raw) > 0,
}
}
pub fn pattern(&self) -> &str {
match self {
Self::Contains(pattern) | Self::ExactLine(pattern) => pattern,
Self::UnescapedQuote => "\"",
}
}
}
pub fn ends_inside_quoted_value(raw: &str) -> bool {
unescaped_quote_count(raw) % 2 == 1
}
fn unescaped_quote_count(raw: &str) -> usize {
let mut count = 0usize;
let mut escaped = false;
for byte in raw.bytes() {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
count += 1;
}
}
count
}
#[derive(Debug, Clone, Copy)]
pub struct IosLikeDialect {
name: &'static str,
key_hint: fn(Option<&ParsedLineParts>) -> Option<String>,
}
impl IosLikeDialect {
pub const fn new(
name: &'static str,
key_hint: fn(Option<&ParsedLineParts>) -> Option<String>,
) -> Self {
Self { name, key_hint }
}
}
impl Dialect for IosLikeDialect {
fn dialect_hint(&self) -> DialectHint {
DialectHint::Named(self.name.to_string())
}
fn classify_trivia(&self, raw: &str) -> TriviaKind {
classify_ios_like_trivia(raw)
}
fn parse_parts(&self, raw: &str) -> Option<ParsedLineParts> {
parse_ios_like_parts(raw)
}
fn key_hint(
&self,
_raw: &str,
parsed: Option<&ParsedLineParts>,
trivia: TriviaKind,
) -> Option<String> {
if trivia != TriviaKind::Content {
return None;
}
(self.key_hint)(parsed)
}
fn literal_region(&self, raw: &str) -> Option<LiteralTerminator> {
ios_like_literal_region(raw)
}
}
pub fn ios_like_literal_region(raw: &str) -> Option<LiteralTerminator> {
let after_head = raw.trim_start().strip_prefix("banner")?;
if !after_head.starts_with([' ', '\t']) {
return None;
}
let (_banner_type, after_type) = split_first_token(after_head)?;
let Some((delimiter, tail)) = split_first_token(after_type) else {
return Some(LiteralTerminator::ExactLine("EOF".to_string()));
};
if tail.contains(delimiter) {
return None;
}
let opener = delimiter_opener(delimiter);
if delimiter.len() > opener.len() {
if delimiter.ends_with(opener) {
return None;
}
if !opener.starts_with(char::is_alphanumeric) {
if delimiter[opener.len()..].contains(opener) || tail.contains(opener) {
return None;
}
return Some(LiteralTerminator::Contains(opener.to_string()));
}
}
Some(LiteralTerminator::Contains(delimiter.to_string()))
}
fn delimiter_opener(token: &str) -> &str {
let mut chars = token.chars();
let Some(first) = chars.next() else {
return token;
};
match chars.next() {
Some(second)
if first == '^'
&& (second == 'C' || token.len() == first.len_utf8() + second.len_utf8()) =>
{
&token[..first.len_utf8() + second.len_utf8()]
}
_ => &token[..first.len_utf8()],
}
}
fn split_first_token(raw: &str) -> Option<(&str, &str)> {
let trimmed = raw.trim_start();
if trimmed.is_empty() {
return None;
}
let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
Some(trimmed.split_at(end))
}
#[derive(Debug, Default, Clone, Copy)]
pub struct GenericDialect;
impl Dialect for GenericDialect {
fn dialect_hint(&self) -> DialectHint {
DialectHint::Generic
}
fn classify_trivia(&self, raw: &str) -> TriviaKind {
classify_trivia(raw)
}
fn parse_parts(&self, raw: &str) -> Option<ParsedLineParts> {
parse_parts(raw)
}
}
pub fn parse_with_dialect<D: Dialect>(input: &str, dialect: &D) -> Document {
let mut doc = Document::new(DocumentMetadata {
source_name: None,
dialect_hint: dialect.dialect_hint(),
original_bytes: input.len(),
line_count: 0,
parse_findings: Vec::new(),
});
let lines = collect_lines(
input,
dialect,
&mut doc.metadata.line_count,
&mut doc.metadata.parse_findings,
);
let mut parent_stack: Vec<(usize, NodeId)> = Vec::new();
let mut next_content_indent: Vec<Option<usize>> = vec![None; lines.len()];
{
let mut last_content_indent: Option<usize> = None;
for i in (0..lines.len()).rev() {
next_content_indent[i] = last_content_indent;
if lines[i].trivia == TriviaKind::Content {
last_content_indent = Some(lines[i].indent);
}
}
}
let opens_block: Vec<bool> = (0..lines.len())
.map(|idx| {
lines[idx].trivia == TriviaKind::Content
&& next_content_indent[idx].is_some_and(|next| next > lines[idx].indent)
})
.collect();
let is_terminator: Vec<bool> = (0..lines.len())
.map(|idx| {
lines[idx].trivia == TriviaKind::Content
&& !opens_block[idx]
&& dialect.block_terminator(&lines[idx].raw)
})
.collect();
for (idx, line) in lines.into_iter().enumerate() {
if line.trivia == TriviaKind::Content && line.indent > 0 && parent_stack.is_empty() {
doc.metadata.parse_findings.push(ParseFinding {
code: "orphan-indentation".to_string(),
message: "indented content line without an open parent block; line kept as-is"
.to_string(),
span: line.span.clone(),
});
}
let mut closed_block: Option<NodeId> = None;
if !line.in_region && !matches!(line.trivia, TriviaKind::Blank | TriviaKind::Literal) {
while let Some((parent_indent, parent_id)) = parent_stack.last().copied() {
if line.indent <= parent_indent {
closed_block = Some(parent_id);
parent_stack.pop();
} else {
break;
}
}
}
if is_terminator[idx]
&& let Some(block_id) = closed_block
&& let Some(Node::Block(block)) = doc.arena.get_mut(block_id.0)
&& block.footer.is_none()
{
block.footer = Some(line.into_line_node());
continue;
}
let indent = line.indent;
if opens_block[idx] {
let block = Node::Block(BlockNode {
header: line.into_line_node(),
children: Vec::new(),
footer: None,
kind_label: None,
});
let id = doc.insert_node(block);
attach_node(&mut doc, &parent_stack, id);
parent_stack.push((indent, id));
} else {
let id = doc.insert_node(Node::Line(line.into_line_node()));
attach_node(&mut doc, &parent_stack, id);
}
}
doc
}
#[derive(Debug)]
struct LineCandidate {
raw: String,
line_ending: String,
span: Span,
parsed: Option<ParsedLineParts>,
key_hint: Option<String>,
trivia: TriviaKind,
indent: usize,
in_region: bool,
}
impl LineCandidate {
fn into_line_node(self) -> LineNode {
LineNode {
raw: self.raw,
line_ending: self.line_ending,
span: self.span,
parsed: self.parsed,
key_hint: self.key_hint,
trivia: self.trivia,
}
}
}
struct RawLine<'a> {
raw: &'a str,
line_ending: &'a str,
span: Span,
}
fn collect_lines<D: Dialect>(
input: &str,
dialect: &D,
line_count: &mut usize,
parse_findings: &mut Vec<ParseFinding>,
) -> Vec<LineCandidate> {
let raw_lines = split_raw_lines(input);
*line_count = raw_lines.len();
let regions = mark_regions(&raw_lines, dialect, parse_findings);
raw_lines
.into_iter()
.zip(regions)
.map(|(line, region)| {
let RawLine {
raw,
line_ending,
span,
} = line;
let trivia = match region {
Some(RegionKind::Literal) => TriviaKind::Literal,
Some(RegionKind::Comment) => TriviaKind::Comment,
None => dialect.classify_trivia(raw),
};
let parsed = if trivia == TriviaKind::Content {
dialect.parse_parts(raw)
} else {
None
};
let key_hint = dialect.key_hint(raw, parsed.as_ref(), trivia);
if region.is_none() && has_mixed_leading_whitespace(raw) {
parse_findings.push(ParseFinding {
code: "mixed-leading-whitespace".to_string(),
message: "line indentation mixes spaces and tabs; structure may be ambiguous"
.to_string(),
span: span.clone(),
});
}
LineCandidate {
raw: raw.to_string(),
line_ending: line_ending.to_string(),
span,
parsed,
key_hint,
trivia,
indent: count_indent(raw),
in_region: region.is_some(),
}
})
.collect()
}
fn split_raw_lines(input: &str) -> Vec<RawLine<'_>> {
let mut out = Vec::new();
let mut start = 0usize;
let mut line_no = 1usize;
while start < input.len() {
let next_lf = input[start..].find('\n').map(|idx| start + idx);
let (segment, next_start) = if let Some(lf_idx) = next_lf {
(&input[start..=lf_idx], lf_idx + 1)
} else {
(&input[start..], input.len())
};
let (raw, line_ending) = split_line_ending(segment);
out.push(RawLine {
raw,
line_ending,
span: Span {
line: line_no,
start_byte: start,
end_byte: start + raw.len(),
},
});
line_no += 1;
start = next_start;
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RegionKind {
Literal,
Comment,
}
fn mark_regions<D: Dialect>(
lines: &[RawLine<'_>],
dialect: &D,
parse_findings: &mut Vec<ParseFinding>,
) -> Vec<Option<RegionKind>> {
let mut regions = vec![None; lines.len()];
let mut idx = 0usize;
while idx < lines.len() {
let opened = dialect
.comment_region(lines[idx].raw)
.map(|terminator| (RegionKind::Comment, terminator))
.or_else(|| {
dialect
.literal_region(lines[idx].raw)
.map(|terminator| (RegionKind::Literal, terminator))
});
let Some((kind, terminator)) = opened else {
idx += 1;
continue;
};
match (idx + 1..lines.len()).find(|&next| terminator.terminates(lines[next].raw)) {
Some(end) => {
regions[idx + 1..=end].fill(Some(kind));
idx = end + 1;
}
None => {
let (code, noun) = match kind {
RegionKind::Literal => ("unterminated-literal-region", "literal"),
RegionKind::Comment => ("unterminated-comment-region", "comment"),
};
parse_findings.push(ParseFinding {
code: code.to_string(),
message: format!(
"{noun} region is never closed by `{}`; its body is parsed as configuration",
terminator.pattern()
),
span: lines[idx].span.clone(),
});
idx += 1;
}
}
}
regions
}
fn attach_node(doc: &mut Document, parent_stack: &[(usize, NodeId)], id: NodeId) {
if let Some((_, parent_id)) = parent_stack.last() {
if !doc.add_child(*parent_id, id) {
doc.roots.push(id);
}
} else {
doc.roots.push(id);
}
}
fn split_line_ending(segment: &str) -> (&str, &str) {
if let Some(raw) = segment.strip_suffix("\r\n") {
(raw, "\r\n")
} else if let Some(raw) = segment.strip_suffix('\n') {
(raw, "\n")
} else {
(segment, "")
}
}
pub fn classify_trivia_with_prefixes(raw: &str, comment_prefixes: &[&str]) -> TriviaKind {
if raw.trim().is_empty() {
return TriviaKind::Blank;
}
let trimmed = raw.trim_start();
if comment_prefixes.iter().any(|p| trimmed.starts_with(p)) {
return TriviaKind::Comment;
}
TriviaKind::Content
}
fn classify_trivia(raw: &str) -> TriviaKind {
classify_trivia_with_prefixes(raw, &["#", "!", "//"])
}
fn parse_parts(raw: &str) -> Option<ParsedLineParts> {
let mut tokens = raw.split_whitespace();
let head = tokens.next()?;
let args = tokens.map(ToString::to_string).collect::<Vec<_>>();
Some(ParsedLineParts {
head: head.to_string(),
args,
})
}
pub fn tokenize(raw: &str, punctuation: &[char]) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quote: Option<char> = None;
let mut escape = false;
for ch in raw.chars() {
if let Some(q) = in_quote {
if escape {
current.push(ch);
escape = false;
continue;
}
if ch == '\\' {
current.push(ch);
escape = true;
continue;
}
current.push(ch);
if ch == q {
in_quote = None;
}
continue;
}
match ch {
'"' | '\'' => {
current.push(ch);
in_quote = Some(ch);
}
c if punctuation.contains(&c) => {
let trimmed = current.trim();
if !trimmed.is_empty() {
tokens.push(trimmed.to_string());
}
current.clear();
tokens.push(ch.to_string());
}
c if c.is_whitespace() => {
let trimmed = current.trim();
if !trimmed.is_empty() {
tokens.push(trimmed.to_string());
current.clear();
}
}
_ => current.push(ch),
}
}
let trimmed = current.trim();
if !trimmed.is_empty() {
tokens.push(trimmed.to_string());
}
tokens
}
pub struct IosKeyHintConfig {
pub interface_types: &'static [&'static str],
pub vrf_keyword: &'static str,
pub extra_router_protos: &'static [&'static str],
}
pub fn ios_family_key_hint(
parsed: Option<&ParsedLineParts>,
config: &IosKeyHintConfig,
) -> Option<String> {
let parsed_ref = parsed?;
let head = parsed_ref.head.as_str();
let args = parsed_ref.args.as_slice();
match head {
"interface" => {
let name = args.first()?;
if let Some((itype, id)) = parse_interface(name, config.interface_types) {
Some(format!("interface:{itype}:{id}"))
} else {
Some(format!("interface:{name}"))
}
}
"vrf" => match args {
[sub, name, ..] if sub == config.vrf_keyword => Some(format!("vrf:{name}")),
[name, ..] => Some(format!("vrf:{name}")),
_ => None,
},
"router" => match args {
[proto, id, ..] if proto == "bgp" => Some(format!("router:bgp:{id}")),
[proto, id, ..] if proto == "ospf" => Some(format!("router:ospf:{id}")),
[proto, id, ..] if config.extra_router_protos.contains(&proto.as_str()) => {
Some(format!("router:{proto}:{id}"))
}
[proto, ..] => Some(format!("router:{proto}")),
_ => None,
},
"ip" => match args {
[next, kind, name, ..] if next == "access-list" => {
Some(format!("ip-access-list:{kind}:{name}"))
}
[next, name] if next == "access-list" => Some(format!("ip-access-list:{name}")),
[next, name, ..] if next == "prefix-list" => Some(format!("prefix-list:{name}")),
[next, kind, name, ..] if next == "community-list" => {
Some(format!("ip-community-list:{kind}:{name}"))
}
[next, vrf_kw, vrf_name, prefix, ..] if next == "route" && vrf_kw == "vrf" => {
Some(format!("ip-route:{vrf_name}:{prefix}"))
}
[next, prefix, ..] if next == "route" => Some(format!("ip-route:{prefix}")),
_ => None,
},
_ => None,
}
}
pub fn common_key_hint(parsed: Option<&ParsedLineParts>) -> Option<String> {
let parsed = parsed?;
let head = parsed.head.as_str();
let args = parsed.args.as_slice();
match head {
"vlan" => match args {
[sub, id, ..] if sub == "configuration" => Some(format!("vlan-configuration:{id}")),
[id, ..] => Some(format!("vlan:{id}")),
_ => None,
},
"route-map" => match args {
[name, action, seq, ..] => Some(format!("route-map:{name}:{action}:{seq}")),
[name, action] => Some(format!("route-map:{name}:{action}")),
_ => None,
},
"class-map" => match args {
[_match_kind, name, ..] => Some(format!("class-map:{name}")),
[name] => Some(format!("class-map:{name}")),
_ => None,
},
"policy-map" => args.first().map(|name| format!("policy-map:{name}")),
"ipv6" => match args {
[next, name, ..] if next == "access-list" => Some(format!("ipv6-access-list:{name}")),
[next, name, ..] if next == "prefix-list" => Some(format!("ipv6-prefix-list:{name}")),
[next, vrf_kw, vrf_name, prefix, ..] if next == "route" && vrf_kw == "vrf" => {
Some(format!("ipv6-route:{vrf_name}:{prefix}"))
}
[next, prefix, ..] if next == "route" => Some(format!("ipv6-route:{prefix}")),
_ => None,
},
"crypto" => match args {
[kind, sub, name, ..] if kind == "ikev2" => Some(format!("crypto:ikev2:{sub}:{name}")),
[kind, sub, name, ..] if kind == "ipsec" => Some(format!("crypto:ipsec:{sub}:{name}")),
[kind, name, ..] if kind == "map" => Some(format!("crypto:map:{name}")),
[kind, num, ..] if kind == "isakmp" => Some(format!("crypto:isakmp:{num}")),
_ => None,
},
"spanning-tree" => match args {
[next, id, ..] if next == "vlan" => Some(format!("spanning-tree:vlan:{id}")),
_ => None,
},
"line" => match args {
[kind, from, to, ..] => Some(format!("line:{kind}:{from}:{to}")),
[kind, one, ..] => Some(format!("line:{kind}:{one}")),
_ => None,
},
"monitor" => match args {
[sub, id, ..] if sub == "session" => Some(format!("monitor-session:{id}")),
_ => None,
},
"ntp" => match args {
[kind, addr, ..] if kind == "server" || kind == "peer" => {
Some(format!("ntp:{kind}:{addr}"))
}
_ => None,
},
_ => None,
}
}
pub fn parse_interface<'a>(
name: &'a str,
types: &[&'static str],
) -> Option<(&'static str, &'a str)> {
let lower = name.to_ascii_lowercase();
for &canonical in types {
if lower.starts_with(canonical) && name.len() > canonical.len() {
let id = &name[canonical.len()..];
return Some((canonical, id));
}
}
None
}
pub fn classify_ios_like_trivia(raw: &str) -> TriviaKind {
classify_trivia_with_prefixes(raw, &["!", "#"])
}
pub fn parse_ios_like_parts(raw: &str) -> Option<ParsedLineParts> {
let tokens = tokenize(raw, &[]);
let head = tokens.first()?.clone();
let args = tokens.into_iter().skip(1).collect::<Vec<_>>();
Some(ParsedLineParts { head, args })
}
pub fn count_indent(raw: &str) -> usize {
let mut width = 0usize;
for ch in raw.chars() {
match ch {
' ' => width += 1,
'\t' => width += 4,
_ => break,
}
}
width
}
fn has_mixed_leading_whitespace(raw: &str) -> bool {
let mut seen_space = false;
let mut seen_tab = false;
for ch in raw.chars() {
match ch {
' ' => seen_space = true,
'\t' => seen_tab = true,
_ => break,
}
}
seen_space && seen_tab
}
impl fmt::Display for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn common_hint(line: &str) -> Option<String> {
let parsed = parse_ios_like_parts(line);
common_key_hint(parsed.as_ref())
}
#[test]
fn common_key_hint_vlan() {
assert_eq!(common_hint("vlan 100"), Some("vlan:100".into()));
}
#[test]
fn common_key_hint_vlan_configuration() {
assert_eq!(
common_hint("vlan configuration 10"),
Some("vlan-configuration:10".into()),
);
assert_eq!(common_hint("vlan 100"), Some("vlan:100".into()));
}
#[test]
fn common_key_hint_route_map() {
assert_eq!(
common_hint("route-map REDISTRIBUTE permit 10"),
Some("route-map:REDISTRIBUTE:permit:10".into()),
);
}
#[test]
fn common_key_hint_class_map() {
assert_eq!(
common_hint("class-map match-all VOICE"),
Some("class-map:VOICE".into()),
);
}
#[test]
fn common_key_hint_policy_map() {
assert_eq!(
common_hint("policy-map QOS-POLICY"),
Some("policy-map:QOS-POLICY".into()),
);
}
#[test]
fn common_key_hint_access_list_has_no_hint() {
assert_eq!(common_hint("access-list 100 permit ip any any"), None);
}
#[test]
fn common_key_hint_crypto() {
assert_eq!(
common_hint("crypto ikev2 proposal PROP-1"),
Some("crypto:ikev2:proposal:PROP-1".into()),
);
}
#[test]
fn common_key_hint_spanning_tree() {
assert_eq!(
common_hint("spanning-tree vlan 1-100 priority 4096"),
Some("spanning-tree:vlan:1-100".into()),
);
}
#[test]
fn common_key_hint_line() {
assert_eq!(common_hint("line vty 0 4"), Some("line:vty:0:4".into()));
}
#[test]
fn common_key_hint_monitor() {
assert_eq!(
common_hint("monitor session 1"),
Some("monitor-session:1".into()),
);
}
#[test]
fn common_key_hint_ntp() {
assert_eq!(
common_hint("ntp server 10.0.0.1"),
Some("ntp:server:10.0.0.1".into()),
);
}
#[test]
fn common_key_hint_ipv6() {
assert_eq!(
common_hint("ipv6 access-list BLOCK-BOGONS"),
Some("ipv6-access-list:BLOCK-BOGONS".into()),
);
}
#[test]
fn common_key_hint_none_for_dialect_specific() {
assert_eq!(common_hint("interface Ethernet1"), None);
assert_eq!(common_hint("vrf MGMT"), None);
assert_eq!(common_hint("router bgp 65001"), None);
assert_eq!(common_hint("ip access-list extended ACL"), None);
}
#[test]
fn common_key_hint_none_on_empty() {
assert_eq!(common_key_hint(None), None);
}
#[test]
fn common_key_hint_class_map_match_any() {
assert_eq!(
common_hint("class-map match-any WEB-TRAFFIC"),
Some("class-map:WEB-TRAFFIC".into()),
);
}
#[test]
fn common_key_hint_class_map_bare() {
assert_eq!(
common_hint("class-map SIMPLE"),
Some("class-map:SIMPLE".into())
);
}
#[test]
fn common_key_hint_crypto_map() {
assert_eq!(
common_hint("crypto map VPN-MAP 10 ipsec-isakmp"),
Some("crypto:map:VPN-MAP".into()),
);
}
#[test]
fn common_key_hint_crypto_isakmp() {
assert_eq!(
common_hint("crypto isakmp policy 10"),
Some("crypto:isakmp:policy".into()),
);
}
#[test]
fn common_key_hint_crypto_ipsec() {
assert_eq!(
common_hint("crypto ipsec transform-set AES-SHA esp-aes esp-sha-hmac"),
Some("crypto:ipsec:transform-set:AES-SHA".into()),
);
}
#[test]
fn common_key_hint_ipv6_prefix_list() {
assert_eq!(
common_hint("ipv6 prefix-list DEFAULT-V6-ONLY"),
Some("ipv6-prefix-list:DEFAULT-V6-ONLY".into()),
);
}
#[test]
fn common_key_hint_ipv6_route() {
assert_eq!(
common_hint("ipv6 route 2001:db8::/32 Null0"),
Some("ipv6-route:2001:db8::/32".into()),
);
}
#[test]
fn common_key_hint_ipv6_route_vrf() {
assert_eq!(
common_hint("ipv6 route vrf MGMT ::/0 GigabitEthernet0/0 fe80::1"),
Some("ipv6-route:MGMT:::/0".into()),
);
}
#[test]
fn common_key_hint_ipv6_no_match() {
assert_eq!(common_hint("ipv6 unicast-routing"), None);
}
#[test]
fn common_key_hint_spanning_tree_no_match() {
assert_eq!(common_hint("spanning-tree mode rapid-pvst"), None);
}
#[test]
fn common_key_hint_monitor_no_session() {
assert_eq!(common_hint("monitor copp-system-p-policy"), None);
}
#[test]
fn common_key_hint_ntp_peer() {
assert_eq!(
common_hint("ntp peer 10.0.0.2"),
Some("ntp:peer:10.0.0.2".into())
);
}
#[test]
fn common_key_hint_ntp_no_match() {
assert_eq!(common_hint("ntp source-interface mgmt0"), None);
}
#[test]
fn literal_region_delimiter_form() {
assert_eq!(
ios_like_literal_region("banner motd ^C"),
Some(LiteralTerminator::Contains("^C".into())),
);
assert_eq!(
ios_like_literal_region("banner login #"),
Some(LiteralTerminator::Contains("#".into())),
);
}
#[test]
fn literal_region_accepts_control_byte_delimiter() {
assert_eq!(
ios_like_literal_region("banner motd \u{3}"),
Some(LiteralTerminator::Contains("\u{3}".into())),
);
}
#[test]
fn literal_region_delimiter_less_eos_form() {
assert_eq!(
ios_like_literal_region("banner motd"),
Some(LiteralTerminator::ExactLine("EOF".into())),
);
}
#[test]
fn literal_region_declines_self_contained_banner() {
assert_eq!(ios_like_literal_region("banner motd ^C Hi there ^C"), None);
assert_eq!(ios_like_literal_region("banner motd ^CHi^C"), None);
assert_eq!(ios_like_literal_region("banner motd ^C^C"), None);
assert_eq!(ios_like_literal_region("banner motd #Hi#"), None);
}
#[test]
fn literal_region_keeps_multi_character_delimiters_whole() {
assert_eq!(
ios_like_literal_region("banner motd EOF"),
Some(LiteralTerminator::Contains("EOF".into())),
);
assert_eq!(ios_like_literal_region("banner motd \u{3}Hi\u{3}"), None,);
}
#[test]
fn literal_region_splits_caret_delimiter_from_glued_text() {
assert_eq!(
ios_like_literal_region("banner motd ^CHi"),
Some(LiteralTerminator::Contains("^C".into())),
);
}
#[test]
fn literal_region_splits_a_plain_caret_delimiter_from_glued_text() {
assert_eq!(
ios_like_literal_region("banner motd ^Warning restricted"),
Some(LiteralTerminator::Contains("^".into())),
);
assert_eq!(
ios_like_literal_region("banner motd ^1st line"),
Some(LiteralTerminator::Contains("^".into())),
);
}
#[test]
fn literal_region_keeps_a_standalone_caret_escape_whole() {
for delimiter in ["^C", "^Z", "^A", "^X", "^^", "^é"] {
assert_eq!(
ios_like_literal_region(&format!("banner motd {delimiter}")),
Some(LiteralTerminator::Contains(delimiter.into())),
"{delimiter}",
);
assert_eq!(
ios_like_literal_region(&format!("banner motd {delimiter} Authorized use only")),
Some(LiteralTerminator::Contains(delimiter.into())),
"{delimiter}",
);
}
}
#[test]
fn literal_region_splits_a_non_c_caret_escape_glued_to_its_text() {
assert_eq!(
ios_like_literal_region("banner motd ^Zdanger"),
Some(LiteralTerminator::Contains("^".into())),
);
}
#[test]
fn literal_region_splits_punctuation_delimiter_from_glued_text() {
assert_eq!(
ios_like_literal_region("banner motd #Warning restricted"),
Some(LiteralTerminator::Contains("#".into())),
);
assert_eq!(
ios_like_literal_region("banner login @Hi"),
Some(LiteralTerminator::Contains("@".into())),
);
assert_eq!(
ios_like_literal_region("banner motd \u{3}Warning"),
Some(LiteralTerminator::Contains("\u{3}".into())),
);
}
#[test]
fn literal_region_declines_a_one_line_banner_whose_text_contains_a_space() {
assert_eq!(ios_like_literal_region("banner motd #Hello world#"), None);
assert_eq!(ios_like_literal_region("banner motd ^CHello world^C"), None);
assert_eq!(ios_like_literal_region("banner motd ^Hello world^"), None);
assert_eq!(
ios_like_literal_region("banner exec %Please log out at %the end%"),
None,
);
assert_eq!(
ios_like_literal_region("banner motd \u{3}Hello world\u{3}"),
None,
);
assert_eq!(
ios_like_literal_region("banner motd #Hello world"),
Some(LiteralTerminator::Contains("#".into())),
);
assert_eq!(
ios_like_literal_region("banner motd ^CHello world"),
Some(LiteralTerminator::Contains("^C".into())),
);
assert_eq!(
ios_like_literal_region("banner motd ^Hello world"),
Some(LiteralTerminator::Contains("^".into())),
);
}
#[test]
fn literal_region_opens_when_text_starts_on_the_banner_line() {
assert_eq!(
ios_like_literal_region("banner motd ^C Authorized use only"),
Some(LiteralTerminator::Contains("^C".into())),
);
}
#[test]
fn literal_region_declines_non_banner_lines() {
assert_eq!(ios_like_literal_region("interface Ethernet1"), None);
assert_eq!(ios_like_literal_region("bannermotd ^C"), None);
assert_eq!(ios_like_literal_region("no banner motd"), None);
assert_eq!(ios_like_literal_region("banner"), None);
assert_eq!(ios_like_literal_region("banner "), None);
}
#[test]
fn literal_region_ignores_leading_indentation() {
assert_eq!(
ios_like_literal_region(" banner motd ^C"),
Some(LiteralTerminator::Contains("^C".into())),
);
}
#[test]
fn terminator_contains_matches_anywhere_on_the_line() {
let term = LiteralTerminator::Contains("^C".into());
assert!(term.terminates("^C"));
assert!(term.terminates(" ^C "));
assert!(term.terminates("goodbye^C"));
assert!(!term.terminates("no delimiter here"));
assert_eq!(term.pattern(), "^C");
}
#[test]
fn terminator_exact_line_ignores_surrounding_whitespace_only() {
let term = LiteralTerminator::ExactLine("EOF".into());
assert!(term.terminates("EOF"));
assert!(term.terminates(" EOF "));
assert!(!term.terminates("EOF and more"));
assert!(!term.terminates("not EOF"));
assert_eq!(term.pattern(), "EOF");
}
#[test]
fn terminator_unescaped_quote_matches_a_quote_anywhere_on_the_line() {
let term = LiteralTerminator::UnescapedQuote;
assert!(term.terminates("\""));
assert!(term.terminates("-----END CERTIFICATE-----\""));
assert!(term.terminates("</html>\" "));
assert!(!term.terminates("MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkq"));
assert!(!term.terminates(""));
assert_eq!(term.pattern(), "\"");
}
#[test]
fn terminator_unescaped_quote_respects_backslash_parity() {
let term = LiteralTerminator::UnescapedQuote;
assert!(!term.terminates(r#"<a href=\"help\">"#));
assert!(term.terminates(r#"a windows path C:\\""#));
assert!(!term.terminates(r#"C:\\\""#));
assert!(term.terminates(r#"C:\\\\""#));
}
#[test]
fn ends_inside_quoted_value_tracks_quote_state() {
assert!(ends_inside_quoted_value(
" set private-key \"-----BEGIN ENCRYPTED PRIVATE KEY-----"
));
assert!(!ends_inside_quoted_value(" set hostname \"FortiGate\""));
assert!(!ends_inside_quoted_value(" edit \"port1\""));
assert!(!ends_inside_quoted_value(" set range global"));
assert!(!ends_inside_quoted_value(""));
}
#[test]
fn ends_inside_quoted_value_ignores_escaped_quotes() {
assert!(ends_inside_quoted_value(
r#" set buffer "<a href=\"/help\">Help</a>"#
));
assert!(!ends_inside_quoted_value(
r#" set comment "he said \"hi\"""#
));
assert!(ends_inside_quoted_value(r#" set path "C:\\"#));
assert!(!ends_inside_quoted_value(r#" set path "C:\\""#));
}
#[test]
fn ends_inside_quoted_value_handles_multi_byte_text() {
assert!(ends_inside_quoted_value(" set comment \"Grüße"));
assert!(!ends_inside_quoted_value(" set comment \"Grüße\""));
assert!(!ends_inside_quoted_value(r#" set comment "\é""#));
}
}