Add server configuration editing with byte preservation
This commit is contained in:
@@ -0,0 +1,630 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user