631 lines
24 KiB
Rust
631 lines
24 KiB
Rust
//! Lossless byte-span representation and redacted public inspection.
|
|
//!
|
|
//! The supported input encoding is UTF-8. Input that is not valid UTF-8 is
|
|
//! rejected, without including any source bytes in the error. The document
|
|
//! retains the exact input bytes, including mixed line endings and a missing
|
|
//! final newline. Public views expose only section/key spans, peer public keys,
|
|
//! and parsed AllowedIPs; no arbitrary directive values are exposed because
|
|
//! they may contain secrets.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::fmt;
|
|
use std::str::FromStr;
|
|
|
|
use ipnet::IpNet;
|
|
use thiserror::Error;
|
|
|
|
use crate::domain::PublicKey;
|
|
|
|
use super::metadata::{self, MetadataAttachment, MetadataIssue};
|
|
|
|
/// A half-open byte range into the original UTF-8 input.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct SourceSpan {
|
|
start: usize,
|
|
end: usize,
|
|
}
|
|
|
|
impl SourceSpan {
|
|
/// Construct a span from byte offsets. Returns `None` when the end precedes the start.
|
|
pub const fn from_offsets(start: usize, end: usize) -> Option<Self> {
|
|
if start <= end {
|
|
Some(Self { start, end })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub const fn start(self) -> usize {
|
|
self.start
|
|
}
|
|
|
|
pub const fn end(self) -> usize {
|
|
self.end
|
|
}
|
|
|
|
pub const fn len(self) -> usize {
|
|
self.end - self.start
|
|
}
|
|
|
|
pub const fn is_empty(self) -> bool {
|
|
self.start == self.end
|
|
}
|
|
}
|
|
|
|
/// The section type recognized from a bracketed section header.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum SectionKind {
|
|
Interface,
|
|
Peer,
|
|
Other,
|
|
}
|
|
|
|
/// A recognized section and its original byte boundaries.
|
|
#[derive(Clone, Eq, PartialEq)]
|
|
pub struct DocumentSection {
|
|
pub kind: SectionKind,
|
|
/// Header name after trimming surrounding ASCII whitespace.
|
|
pub name: String,
|
|
/// Includes the header and section body, ending immediately before the next header.
|
|
pub span: SourceSpan,
|
|
/// Header line only, excluding its original line ending.
|
|
pub header_span: SourceSpan,
|
|
pub line: usize,
|
|
}
|
|
|
|
impl fmt::Debug for DocumentSection {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("DocumentSection")
|
|
.field("kind", &self.kind)
|
|
.field("span", &self.span)
|
|
.field("header_span", &self.header_span)
|
|
.field("line", &self.line)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Spans of a recognized key/value directive; values remain private to the document.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct DirectiveSpan {
|
|
pub line: usize,
|
|
pub whole: SourceSpan,
|
|
pub key: SourceSpan,
|
|
pub value: SourceSpan,
|
|
}
|
|
|
|
/// A public, secret-safe inspection of one `[Peer]` section.
|
|
///
|
|
/// Conversion to the narrow `domain::Client` model is intentionally deferred
|
|
/// to the M2.3 adapter: this view has no alias until managed metadata is parsed.
|
|
/// That conversion must require one unambiguous identity and route shapes that
|
|
/// fit `ClientAddresses`; it must reject rather than truncate other AllowedIPs.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct PeerInspection {
|
|
pub index: usize,
|
|
pub section: SourceSpan,
|
|
pub section_line: usize,
|
|
/// Present only when this section has exactly one valid PublicKey directive.
|
|
pub identity: Option<PublicKey>,
|
|
/// Every valid public key value, even when the section is ambiguous.
|
|
pub public_key_candidates: Vec<PublicKey>,
|
|
/// Every PublicKey directive is retained as a span, including duplicates and malformed values.
|
|
pub public_key_fields: Vec<DirectiveSpan>,
|
|
/// Parsed route values. Unparseable items are represented by `PeerIssue` entries.
|
|
pub allowed_ips: Vec<IpNet>,
|
|
pub allowed_ips_fields: Vec<DirectiveSpan>,
|
|
/// Parsed alias and safe owned block span, or an explicit metadata issue.
|
|
pub metadata: MetadataAttachment,
|
|
pub issues: Vec<PeerIssue>,
|
|
}
|
|
|
|
/// Issues found while inspecting a peer. Variants contain no source text.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum PeerIssue {
|
|
MissingPublicKey,
|
|
MalformedPublicKey { field: usize },
|
|
DuplicatePublicKeyField { count: usize },
|
|
DuplicatePeerIdentity { other_peer_indices: Vec<usize> },
|
|
MalformedAllowedIp { field: usize, item: usize },
|
|
UnsupportedAllowedIpShape { field: usize, item: usize },
|
|
MissingIpv4HostRoute,
|
|
}
|
|
|
|
/// Structural issues found outside peer-level inspection.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum DocumentIssue {
|
|
MalformedSectionHeader { line: usize },
|
|
DirectiveOutsideSection { line: usize },
|
|
MalformedDirective { line: usize },
|
|
ManagedMetadata(MetadataIssue),
|
|
}
|
|
|
|
/// A document-level parse failure. It intentionally never contains input bytes or text.
|
|
#[derive(Debug, Error, Eq, PartialEq)]
|
|
pub enum DocumentError {
|
|
#[error("server configuration is not valid UTF-8")]
|
|
InvalidEncoding,
|
|
#[error("server configuration could not be inspected safely")]
|
|
InvalidStructure,
|
|
}
|
|
|
|
/// Parsed view over the exact caller-supplied configuration bytes.
|
|
pub struct ConfigDocument {
|
|
source: String,
|
|
sections: Vec<DocumentSection>,
|
|
peers: Vec<PeerInspection>,
|
|
issues: Vec<DocumentIssue>,
|
|
}
|
|
|
|
impl fmt::Debug for ConfigDocument {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("ConfigDocument")
|
|
.field("source_len", &self.source.len())
|
|
.field("sections", &self.sections)
|
|
.field("peers", &self.peers)
|
|
.field("issues", &self.issues)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl ConfigDocument {
|
|
/// Parse UTF-8 configuration supplied by the caller while retaining every source byte.
|
|
pub fn parse(source: impl AsRef<[u8]>) -> Result<Self, DocumentError> {
|
|
let bytes = source.as_ref();
|
|
let source = std::str::from_utf8(bytes).map_err(|_| DocumentError::InvalidEncoding)?;
|
|
let mut sections = Vec::<DocumentSection>::new();
|
|
let mut issues = Vec::<DocumentIssue>::new();
|
|
let mut peer_fields = Vec::<(
|
|
usize,
|
|
Vec<DirectiveSpan>,
|
|
Vec<(DirectiveSpan, Vec<(usize, Result<IpNet, ()>)>)>,
|
|
)>::new();
|
|
let mut current_section: Option<usize> = None;
|
|
let mut offset = 0usize;
|
|
let mut line_number = 0usize;
|
|
|
|
for line_with_ending in source.split_inclusive('\n') {
|
|
line_number += 1;
|
|
let line_start = offset;
|
|
offset += line_with_ending.len();
|
|
let content = line_with_ending
|
|
.strip_suffix('\n')
|
|
.unwrap_or(line_with_ending);
|
|
let content = content.strip_suffix('\r').unwrap_or(content);
|
|
let line_end = line_start + content.len();
|
|
let span = SourceSpan {
|
|
start: line_start,
|
|
end: offset,
|
|
};
|
|
let content_span = SourceSpan {
|
|
start: line_start,
|
|
end: line_end,
|
|
};
|
|
let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
|
|
|
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
|
|
continue;
|
|
}
|
|
|
|
if trimmed.starts_with('[') || trimmed.ends_with(']') {
|
|
if let Some(name) = parse_section_name(trimmed) {
|
|
if let Some(previous) = current_section {
|
|
sections[previous].span.end = line_start;
|
|
}
|
|
let normalized = name.to_ascii_lowercase();
|
|
let kind = match normalized.as_str() {
|
|
"interface" => SectionKind::Interface,
|
|
"peer" => SectionKind::Peer,
|
|
_ => SectionKind::Other,
|
|
};
|
|
sections.push(DocumentSection {
|
|
kind,
|
|
name: name.to_owned(),
|
|
span: SourceSpan {
|
|
start: line_start,
|
|
end: source.len(),
|
|
},
|
|
header_span: content_span,
|
|
line: line_number,
|
|
});
|
|
current_section = Some(sections.len() - 1);
|
|
if kind == SectionKind::Peer {
|
|
peer_fields.push((line_number, Vec::new(), Vec::new()));
|
|
}
|
|
continue;
|
|
}
|
|
issues.push(DocumentIssue::MalformedSectionHeader { line: line_number });
|
|
continue;
|
|
}
|
|
|
|
let Some(equal_index) = content.find('=') else {
|
|
issues.push(DocumentIssue::MalformedDirective { line: line_number });
|
|
continue;
|
|
};
|
|
let raw_key = &content[..equal_index];
|
|
let raw_value = &content[equal_index + 1..];
|
|
let key = raw_key.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
|
if key.is_empty() {
|
|
issues.push(DocumentIssue::MalformedDirective { line: line_number });
|
|
continue;
|
|
}
|
|
let raw_key_start = raw_key.len()
|
|
- raw_key
|
|
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
|
|
.len();
|
|
let raw_key_end = raw_key
|
|
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
|
|
.len();
|
|
let raw_value_start = raw_value.len()
|
|
- raw_value
|
|
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
|
|
.len();
|
|
let raw_value_end = raw_value
|
|
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
|
|
.len();
|
|
let directive = DirectiveSpan {
|
|
line: line_number,
|
|
whole: span,
|
|
key: SourceSpan {
|
|
start: line_start + raw_key_start,
|
|
end: line_start + raw_key_end,
|
|
},
|
|
value: SourceSpan {
|
|
start: line_start + equal_index + 1 + raw_value_start,
|
|
end: line_start + equal_index + 1 + raw_value_end,
|
|
},
|
|
};
|
|
let Some(section_index) = current_section else {
|
|
issues.push(DocumentIssue::DirectiveOutsideSection { line: line_number });
|
|
continue;
|
|
};
|
|
if sections[section_index].kind != SectionKind::Peer {
|
|
continue;
|
|
}
|
|
let peer_index = peer_fields.len() - 1;
|
|
let (_, public_keys, allowed_fields) = &mut peer_fields[peer_index];
|
|
if key.eq_ignore_ascii_case("PublicKey") {
|
|
public_keys.push(directive);
|
|
} else if key.eq_ignore_ascii_case("AllowedIPs") {
|
|
let value = &source[directive.value.start..directive.value.end];
|
|
let parsed = value
|
|
.split(',')
|
|
.enumerate()
|
|
.map(|(item, raw)| {
|
|
let item_value = raw.trim();
|
|
(item, IpNet::from_str(item_value).map_err(|_| ()))
|
|
})
|
|
.collect();
|
|
allowed_fields.push((directive, parsed));
|
|
}
|
|
}
|
|
|
|
// `split_inclusive` yields no item for an empty string and its final item does not
|
|
// necessarily end in LF; offset therefore tracks all source bytes exactly.
|
|
let mut peers = Vec::with_capacity(peer_fields.len());
|
|
for (index, (section_line, public_key_fields, allowed_fields)) in
|
|
peer_fields.into_iter().enumerate()
|
|
{
|
|
let Some(section) = sections
|
|
.iter()
|
|
.filter(|section| section.kind == SectionKind::Peer)
|
|
.nth(index)
|
|
else {
|
|
return Err(DocumentError::InvalidStructure);
|
|
};
|
|
let mut peer_issues = Vec::new();
|
|
let parsed_keys: Vec<_> = public_key_fields
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(field, directive)| {
|
|
let value = &source[directive.value.start..directive.value.end];
|
|
match PublicKey::from_str(value) {
|
|
Ok(key) => Some(key),
|
|
Err(_) => {
|
|
peer_issues.push(PeerIssue::MalformedPublicKey { field });
|
|
None
|
|
}
|
|
}
|
|
})
|
|
.collect();
|
|
if public_key_fields.is_empty() {
|
|
peer_issues.push(PeerIssue::MissingPublicKey);
|
|
}
|
|
if public_key_fields.len() > 1 {
|
|
peer_issues.push(PeerIssue::DuplicatePublicKeyField {
|
|
count: public_key_fields.len(),
|
|
});
|
|
}
|
|
let identity = if public_key_fields.len() == 1 {
|
|
parsed_keys[0]
|
|
} else {
|
|
None
|
|
};
|
|
let mut allowed_ips = Vec::new();
|
|
let mut allowed_ips_fields = Vec::new();
|
|
let mut has_ipv4_host_route = false;
|
|
let mut has_ipv6_host_route = false;
|
|
for (field, (directive, items)) in allowed_fields.into_iter().enumerate() {
|
|
allowed_ips_fields.push(directive);
|
|
for (item, result) in items {
|
|
match result {
|
|
Ok(net) => {
|
|
let supported = match net {
|
|
IpNet::V4(network) if network.prefix_len() == 32 => {
|
|
let first = !has_ipv4_host_route;
|
|
has_ipv4_host_route = true;
|
|
first
|
|
}
|
|
IpNet::V6(network) if network.prefix_len() == 128 => {
|
|
let first = !has_ipv6_host_route;
|
|
has_ipv6_host_route = true;
|
|
first
|
|
}
|
|
_ => false,
|
|
};
|
|
if !supported {
|
|
peer_issues
|
|
.push(PeerIssue::UnsupportedAllowedIpShape { field, item });
|
|
}
|
|
allowed_ips.push(net);
|
|
}
|
|
Err(()) => peer_issues.push(PeerIssue::MalformedAllowedIp { field, item }),
|
|
}
|
|
}
|
|
}
|
|
if !has_ipv4_host_route {
|
|
peer_issues.push(PeerIssue::MissingIpv4HostRoute);
|
|
}
|
|
peers.push(PeerInspection {
|
|
index,
|
|
section: section.span,
|
|
section_line,
|
|
identity,
|
|
public_key_candidates: parsed_keys.into_iter().flatten().collect(),
|
|
public_key_fields,
|
|
allowed_ips,
|
|
allowed_ips_fields,
|
|
metadata: MetadataAttachment::Absent,
|
|
issues: peer_issues,
|
|
});
|
|
}
|
|
|
|
let mut by_identity = BTreeMap::<PublicKey, Vec<usize>>::new();
|
|
for peer in &peers {
|
|
for identity in &peer.public_key_candidates {
|
|
by_identity.entry(*identity).or_default().push(peer.index);
|
|
}
|
|
}
|
|
for indices in by_identity.values().filter(|indices| indices.len() > 1) {
|
|
let unique_indices: Vec<_> =
|
|
indices.iter().copied().fold(Vec::new(), |mut acc, index| {
|
|
if !acc.contains(&index) {
|
|
acc.push(index);
|
|
}
|
|
acc
|
|
});
|
|
for index in &unique_indices {
|
|
let other_peer_indices: Vec<_> = unique_indices
|
|
.iter()
|
|
.copied()
|
|
.filter(|other| other != index)
|
|
.collect();
|
|
if other_peer_indices.is_empty() {
|
|
continue;
|
|
}
|
|
peers[*index]
|
|
.issues
|
|
.push(PeerIssue::DuplicatePeerIdentity { other_peer_indices });
|
|
}
|
|
}
|
|
|
|
let peer_headers: Vec<_> = peers.iter().map(|peer| peer.section.start()).collect();
|
|
let (metadata_attachments, metadata_issues) =
|
|
metadata::inspect_document(source, &peer_headers);
|
|
for (peer, attachment) in peers.iter_mut().zip(metadata_attachments) {
|
|
peer.metadata = attachment;
|
|
}
|
|
issues.extend(
|
|
metadata_issues
|
|
.into_iter()
|
|
.map(DocumentIssue::ManagedMetadata),
|
|
);
|
|
|
|
Ok(Self {
|
|
source: source.to_owned(),
|
|
sections,
|
|
peers,
|
|
issues,
|
|
})
|
|
}
|
|
|
|
/// Exact original bytes, without normalization or reserialization.
|
|
pub fn render(&self) -> &[u8] {
|
|
self.source.as_bytes()
|
|
}
|
|
|
|
pub fn sections(&self) -> &[DocumentSection] {
|
|
&self.sections
|
|
}
|
|
|
|
pub fn peers(&self) -> &[PeerInspection] {
|
|
&self.peers
|
|
}
|
|
|
|
pub fn issues(&self) -> &[DocumentIssue] {
|
|
&self.issues
|
|
}
|
|
|
|
/// A raw source slice for an explicitly selected span. Callers must treat it as secret-bearing.
|
|
/// This narrow primitive supports later edit code that owns and replaces exact spans.
|
|
pub fn source_slice(&self, span: SourceSpan) -> Option<&str> {
|
|
self.source.get(span.start..span.end)
|
|
}
|
|
}
|
|
|
|
fn parse_section_name(trimmed: &str) -> Option<&str> {
|
|
let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
|
|
let name = inner.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
|
(!name.is_empty() && !name.contains(['[', ']'])).then_some(name)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
|
|
|
#[test]
|
|
fn wireguard_document_round_trips_line_ending_variants_byte_for_byte() {
|
|
let inputs: &[&[u8]] = &[
|
|
b"[Interface]\nAddress = 10.0.0.1/24\n# admin\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.0.0.2/32\n",
|
|
b"[Interface]\r\nAddress = 10.0.0.1/24\r\n[Peer]\r\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n",
|
|
b"# first\r\n[Interface]\nUnknown = value\r\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n# tail",
|
|
b"[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
];
|
|
for input in inputs {
|
|
let document = ConfigDocument::parse(input).unwrap();
|
|
assert_eq!(document.render(), *input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() {
|
|
let input = format!(
|
|
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 2001:db8::2/128, 0.0.0.0/0\nPresharedKey = dummy-secret\nUnknown = retained\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 2001:db8::3/128\n"
|
|
);
|
|
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
|
assert_eq!(document.peers().len(), 2);
|
|
assert_eq!(document.peers()[0].allowed_ips.len(), 3);
|
|
assert_eq!(document.peers()[1].allowed_ips.len(), 1);
|
|
assert_eq!(document.peers()[0].identity.unwrap().to_string(), KEY_A);
|
|
assert!(document.peers()[0].issues.iter().any(|issue| matches!(
|
|
issue,
|
|
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 2 }
|
|
)));
|
|
assert!(
|
|
document.peers()[1]
|
|
.issues
|
|
.contains(&PeerIssue::MissingIpv4HostRoute)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wireguard_unsupported_route_text_is_preserved_and_reported() {
|
|
let input =
|
|
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = not-a-route, 2001:db8::2/128\n");
|
|
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
|
let peer = &document.peers()[0];
|
|
assert_eq!(peer.allowed_ips.len(), 1);
|
|
assert!(
|
|
peer.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }))
|
|
);
|
|
assert_eq!(document.render(), input.as_bytes());
|
|
assert_eq!(
|
|
document.source_slice(peer.allowed_ips_fields[0].value),
|
|
Some("not-a-route, 2001:db8::2/128")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields() {
|
|
let input = format!(
|
|
"[Peer]\nPresharedKey = secret-a\n[Peer]\nPublicKey = bad-secret-shaped-input\nPublicKey = {KEY_A}\n[Peer]\nPublicKey = {KEY_A}\n"
|
|
);
|
|
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
|
assert!(
|
|
document.peers()[0]
|
|
.issues
|
|
.contains(&PeerIssue::MissingPublicKey)
|
|
);
|
|
assert!(
|
|
document.peers()[1]
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, PeerIssue::MalformedPublicKey { .. }))
|
|
);
|
|
assert!(
|
|
document.peers()[1]
|
|
.issues
|
|
.contains(&PeerIssue::DuplicatePublicKeyField { count: 2 })
|
|
);
|
|
assert!(
|
|
document.peers()[2]
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. }))
|
|
);
|
|
let debug = format!("{document:?}");
|
|
assert!(!debug.contains("secret-a"));
|
|
assert!(!debug.contains("bad-secret-shaped-input"));
|
|
}
|
|
|
|
#[test]
|
|
fn wireguard_errors_and_debug_redact_invalid_text_and_private_values() {
|
|
let error =
|
|
ConfigDocument::parse(b"[Interface]\nPrivateKey = definitely-secret\xff").unwrap_err();
|
|
assert_eq!(error.to_string(), "server configuration is not valid UTF-8");
|
|
assert!(!format!("{error:?}").contains("definitely-secret"));
|
|
let document = ConfigDocument::parse(
|
|
b"[Interface]\nPrivateKey = private-value\n[Peer]\nPresharedKey = peer-secret\n",
|
|
)
|
|
.unwrap();
|
|
let debug = format!("{document:?}");
|
|
assert!(!debug.contains("private-value"));
|
|
assert!(!debug.contains("peer-secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn wireguard_sections_and_directive_spans_are_byte_offsets() {
|
|
let input =
|
|
format!("[Interface]\r\nPrivateKey = hidden\r\n[Peer]\r\nPublicKey = {KEY_A}\r\n");
|
|
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
|
let peer = &document.peers()[0];
|
|
let key_text = document
|
|
.source_slice(peer.public_key_fields[0].value)
|
|
.unwrap();
|
|
assert_eq!(key_text, KEY_A);
|
|
assert_eq!(peer.section.start(), input.find("[Peer]").unwrap());
|
|
assert_eq!(peer.section.end(), input.len());
|
|
}
|
|
|
|
#[test]
|
|
fn wireguard_document_exposes_valid_metadata_and_safe_owned_span() {
|
|
let block = super::super::metadata::render_block("# café = \"laptop\"", None).unwrap();
|
|
let input = format!("{block}[Peer]\nPublicKey = {KEY_A}\n");
|
|
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
|
let peer = &document.peers()[0];
|
|
let MetadataAttachment::Valid {
|
|
metadata,
|
|
block: span,
|
|
} = &peer.metadata
|
|
else {
|
|
panic!("valid attached metadata should be available");
|
|
};
|
|
assert_eq!(metadata.alias, "# café = \"laptop\"");
|
|
assert_eq!(document.source_slice(*span), Some(block.as_str()));
|
|
assert!(document.issues().is_empty());
|
|
assert_eq!(document.render(), input.as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn wireguard_document_reports_unsupported_metadata_without_source_text() {
|
|
let input = format!(
|
|
"# wg-admin:begin\n# wg-admin:version=99\n# wg-admin:alias=c2VjcmV0LW5vdGU\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\n"
|
|
);
|
|
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
|
|
assert!(matches!(
|
|
document.peers()[0].metadata,
|
|
MetadataAttachment::Invalid(MetadataIssue::UnsupportedVersion { .. })
|
|
));
|
|
let debug = format!("{document:?}");
|
|
assert!(!debug.contains("c2VjcmV0LW5vdGU"));
|
|
assert!(!debug.contains("secret-note"));
|
|
assert_eq!(document.render(), input.as_bytes());
|
|
}
|
|
}
|