Add server configuration editing with byte preservation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod startup;
|
||||
pub mod wireguard;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Pure parsing and inspection of caller-supplied WireGuard server configuration.
|
||||
//!
|
||||
//! Parsing never reads or writes files and never changes runtime state.
|
||||
|
||||
pub mod document;
|
||||
pub mod edit;
|
||||
pub mod metadata;
|
||||
|
||||
pub use document::{
|
||||
ConfigDocument, DirectiveSpan, DocumentError, DocumentIssue, DocumentSection, PeerInspection,
|
||||
PeerIssue, SectionKind, SourceSpan,
|
||||
};
|
||||
pub use edit::{
|
||||
EditError, add_client, delete_client, insert_legacy_metadata, project_client, rename_client,
|
||||
};
|
||||
pub use metadata::{ManagedMetadata, MetadataAttachment, MetadataError, MetadataIssue};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Pure, span-based edits to caller-supplied WireGuard server documents.
|
||||
|
||||
use ipnet::IpNet;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::{Client, ClientAddresses, PublicKey, validate_alias};
|
||||
|
||||
use super::document::{ConfigDocument, PeerInspection, PeerIssue};
|
||||
use super::metadata::{MetadataAttachment, MetadataError, render_block};
|
||||
|
||||
/// Redacted failures from an in-memory configuration operation.
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub enum EditError {
|
||||
#[error("the selected client identity is missing")]
|
||||
MissingIdentity,
|
||||
#[error("the selected client identity is ambiguous")]
|
||||
AmbiguousIdentity,
|
||||
#[error("the configuration contains malformed or unsupported structure")]
|
||||
UnsafeDocument,
|
||||
#[error("the selected peer has malformed or unsupported fields")]
|
||||
UnsafePeer,
|
||||
#[error("the client identity already exists")]
|
||||
DuplicateIdentity,
|
||||
#[error("the client alias is invalid")]
|
||||
InvalidAlias,
|
||||
#[error("the creation timestamp is invalid")]
|
||||
InvalidTimestamp,
|
||||
#[error("the peer does not have a usable alias")]
|
||||
MissingAlias,
|
||||
#[error("the peer routes do not fit the supported client address model")]
|
||||
UnsupportedAddresses,
|
||||
#[error("the edited configuration could not be parsed")]
|
||||
ParseFailure,
|
||||
}
|
||||
|
||||
impl From<MetadataError> for EditError {
|
||||
fn from(error: MetadataError) -> Self {
|
||||
match error {
|
||||
MetadataError::InvalidAlias => Self::InvalidAlias,
|
||||
MetadataError::InvalidTimestamp => Self::InvalidTimestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigDocument {
|
||||
/// Add a peer using a caller-provided identity and validated domain client.
|
||||
/// No key generation or filesystem/runtime access occurs here.
|
||||
pub fn add_client(
|
||||
&self,
|
||||
client: &Client,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<Self, EditError> {
|
||||
add_client(self, client, created_utc)
|
||||
}
|
||||
|
||||
/// Delete the uniquely selected peer. Its header and directives are owned by
|
||||
/// the peer; blank lines and administrator comments remain in their original order.
|
||||
pub fn delete_client(&self, identity: &PublicKey) -> Result<Self, EditError> {
|
||||
delete_client(self, identity)
|
||||
}
|
||||
|
||||
/// Rename a peer, inserting a standard block when it is a legacy peer.
|
||||
pub fn rename_client(&self, identity: &PublicKey, alias: &str) -> Result<Self, EditError> {
|
||||
rename_client(self, identity, alias)
|
||||
}
|
||||
|
||||
/// Explicitly add managed alias metadata to a legacy peer.
|
||||
pub fn insert_legacy_metadata(
|
||||
&self,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<Self, EditError> {
|
||||
insert_legacy_metadata(self, identity, alias, created_utc)
|
||||
}
|
||||
|
||||
/// Project one compatible peer and its metadata into the narrower M1 model.
|
||||
pub fn project_client(&self, identity: &PublicKey) -> Result<Client, EditError> {
|
||||
project_client(self, identity)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_client(
|
||||
document: &ConfigDocument,
|
||||
client: &Client,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(&client.alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
if find_peer(document, client.identity).is_ok() {
|
||||
return Err(EditError::DuplicateIdentity);
|
||||
}
|
||||
if !matches!(
|
||||
find_peer(document, client.identity),
|
||||
Err(EditError::MissingIdentity)
|
||||
) {
|
||||
return Err(EditError::AmbiguousIdentity);
|
||||
}
|
||||
let block = render_block(&client.alias, created_utc)?;
|
||||
let newline = line_ending(document.render());
|
||||
let mut source =
|
||||
String::from_utf8(document.render().to_vec()).map_err(|_| EditError::ParseFailure)?;
|
||||
if !source.is_empty() && !source.ends_with('\n') {
|
||||
source.push_str(newline);
|
||||
}
|
||||
let block = block.replace('\n', newline);
|
||||
source.push_str(&block);
|
||||
source.push_str("[Peer]");
|
||||
source.push_str(newline);
|
||||
source.push_str("PublicKey = ");
|
||||
source.push_str(&client.identity.to_string());
|
||||
source.push_str(newline);
|
||||
source.push_str("AllowedIPs = ");
|
||||
source.push_str(&format!("{}/32", client.addresses.ipv4));
|
||||
if let Some(ipv6) = client.addresses.ipv6 {
|
||||
source.push_str(", ");
|
||||
source.push_str(&format!("{ipv6}/128"));
|
||||
}
|
||||
source.push_str(newline);
|
||||
parse_edited(source)
|
||||
}
|
||||
|
||||
pub fn delete_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?;
|
||||
let mut output = String::with_capacity(source.len());
|
||||
let prefix_end = match &peer.metadata {
|
||||
MetadataAttachment::Valid { block, .. } => block.start(),
|
||||
MetadataAttachment::Absent => peer.section.start(),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
output.push_str(&source[..prefix_end]);
|
||||
let section = peer.section;
|
||||
let section_text = &source[section.start()..section.end()];
|
||||
let mut offset = section.start();
|
||||
for raw in section_text.split_inclusive('\n') {
|
||||
let content = raw
|
||||
.strip_suffix('\n')
|
||||
.unwrap_or(raw)
|
||||
.strip_suffix('\r')
|
||||
.unwrap_or(raw.strip_suffix('\n').unwrap_or(raw));
|
||||
let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace());
|
||||
let is_header = offset == section.start();
|
||||
let is_comment_or_blank =
|
||||
trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';');
|
||||
if !is_header && is_comment_or_blank {
|
||||
output.push_str(raw);
|
||||
}
|
||||
offset += raw.len();
|
||||
}
|
||||
output.push_str(&source[section.end()..]);
|
||||
let result = parse_edited(output)?;
|
||||
if result
|
||||
.peers()
|
||||
.iter()
|
||||
.any(|candidate| candidate.public_key_candidates.contains(identity))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn rename_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
let created = match &peer.metadata {
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(),
|
||||
MetadataAttachment::Absent => None,
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
set_metadata(document, identity, alias, created)
|
||||
}
|
||||
|
||||
pub fn insert_legacy_metadata(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
if !matches!(peer.metadata, MetadataAttachment::Absent) {
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
set_metadata(document, identity, alias, created_utc)
|
||||
}
|
||||
|
||||
fn set_metadata(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
alias: &str,
|
||||
created_utc: Option<&str>,
|
||||
) -> Result<ConfigDocument, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
validate_alias(alias).map_err(|_| EditError::InvalidAlias)?;
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
ensure_peer_safe(peer)?;
|
||||
let created = match &peer.metadata {
|
||||
MetadataAttachment::Absent => created_utc,
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.created_utc.as_deref(),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
let rendered = render_block(alias, created)?;
|
||||
let source = std::str::from_utf8(document.render()).map_err(|_| EditError::ParseFailure)?;
|
||||
match &peer.metadata {
|
||||
MetadataAttachment::Absent => {
|
||||
let replacement = rendered.replace('\n', line_ending(document.render()));
|
||||
let mut output = String::with_capacity(source.len() + replacement.len());
|
||||
let at = peer.section.start();
|
||||
output.push_str(&source[..at]);
|
||||
output.push_str(&replacement);
|
||||
output.push_str(&source[at..]);
|
||||
parse_edited(output)
|
||||
}
|
||||
MetadataAttachment::Valid { block, .. } => {
|
||||
let encoded_alias = rendered
|
||||
.lines()
|
||||
.nth(2)
|
||||
.and_then(|line| line.strip_prefix("# wg-admin:alias="))
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let block_source = &source[block.start()..block.end()];
|
||||
let alias_line = block_source
|
||||
.find("# wg-admin:alias=")
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let value_start = block.start() + alias_line + "# wg-admin:alias=".len();
|
||||
let value_end = value_start
|
||||
+ source[value_start..block.end()]
|
||||
.find(['\r', '\n'])
|
||||
.ok_or(EditError::ParseFailure)?;
|
||||
let mut output = String::with_capacity(
|
||||
source.len() - (value_end - value_start) + encoded_alias.len(),
|
||||
);
|
||||
output.push_str(&source[..value_start]);
|
||||
output.push_str(encoded_alias);
|
||||
output.push_str(&source[value_end..]);
|
||||
parse_edited(output)
|
||||
}
|
||||
MetadataAttachment::Invalid(_) => Err(EditError::UnsafePeer),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_client(
|
||||
document: &ConfigDocument,
|
||||
identity: &PublicKey,
|
||||
) -> Result<Client, EditError> {
|
||||
let peer = unique_peer(document, *identity)?;
|
||||
if peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| !is_address_shape_issue(issue))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
let alias = match &peer.metadata {
|
||||
MetadataAttachment::Valid { metadata, .. } => metadata.alias.clone(),
|
||||
MetadataAttachment::Absent => return Err(EditError::MissingAlias),
|
||||
MetadataAttachment::Invalid(_) => return Err(EditError::UnsafePeer),
|
||||
};
|
||||
if !peer.issues.is_empty() {
|
||||
return Err(EditError::UnsupportedAddresses);
|
||||
}
|
||||
let mut ipv4 = None;
|
||||
let mut ipv6 = None;
|
||||
for route in &peer.allowed_ips {
|
||||
match route {
|
||||
IpNet::V4(network) if network.prefix_len() == 32 && ipv4.is_none() => {
|
||||
ipv4 = Some(network.addr());
|
||||
}
|
||||
IpNet::V6(network) if network.prefix_len() == 128 && ipv6.is_none() => {
|
||||
ipv6 = Some(network.addr());
|
||||
}
|
||||
_ => return Err(EditError::UnsupportedAddresses),
|
||||
}
|
||||
}
|
||||
let ipv4 = ipv4.ok_or(EditError::UnsupportedAddresses)?;
|
||||
Client::new(*identity, alias, ClientAddresses { ipv4, ipv6 })
|
||||
.map_err(|_| EditError::InvalidAlias)
|
||||
}
|
||||
|
||||
fn ensure_document_safe(document: &ConfigDocument) -> Result<(), EditError> {
|
||||
if !document.issues().is_empty() {
|
||||
return Err(EditError::UnsafeDocument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_peer(
|
||||
document: &ConfigDocument,
|
||||
identity: PublicKey,
|
||||
) -> Result<&PeerInspection, EditError> {
|
||||
ensure_document_safe(document)?;
|
||||
find_peer(document, identity)
|
||||
}
|
||||
|
||||
fn find_peer(document: &ConfigDocument, identity: PublicKey) -> Result<&PeerInspection, EditError> {
|
||||
let matches: Vec<_> = document
|
||||
.peers()
|
||||
.iter()
|
||||
.filter(|peer| peer.public_key_candidates.contains(&identity))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[] => Err(EditError::MissingIdentity),
|
||||
[peer]
|
||||
if peer.identity == Some(identity)
|
||||
&& !peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. })) =>
|
||||
{
|
||||
Ok(peer)
|
||||
}
|
||||
_ => Err(EditError::AmbiguousIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_peer_safe(peer: &PeerInspection) -> Result<(), EditError> {
|
||||
if peer
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| !is_address_shape_issue(issue))
|
||||
|| matches!(peer.metadata, MetadataAttachment::Invalid(_))
|
||||
{
|
||||
return Err(EditError::UnsafePeer);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_address_shape_issue(issue: &PeerIssue) -> bool {
|
||||
matches!(
|
||||
issue,
|
||||
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_edited(source: String) -> Result<ConfigDocument, EditError> {
|
||||
ConfigDocument::parse(source.as_bytes()).map_err(|_| EditError::ParseFailure)
|
||||
}
|
||||
|
||||
fn line_ending(source: &[u8]) -> &'static str {
|
||||
source
|
||||
.windows(2)
|
||||
.find(|window| *window == b"\r\n")
|
||||
.map_or("\n", |_| "\r\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||
|
||||
fn key(text: &str) -> PublicKey {
|
||||
PublicKey::from_str(text).unwrap()
|
||||
}
|
||||
fn doc(source: &str) -> ConfigDocument {
|
||||
ConfigDocument::parse(source).unwrap()
|
||||
}
|
||||
fn client(identity: &str, alias: &str) -> Client {
|
||||
Client::new(
|
||||
key(identity),
|
||||
alias,
|
||||
ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 0, 0, 7),
|
||||
ipv6: Some("fd00::7".parse().unwrap()),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_add_emits_metadata_dual_stack_and_round_trips() {
|
||||
let original = doc("[Interface]\nAddress = keep\n");
|
||||
let edited = original
|
||||
.add_client(&client(KEY_A, "same alias"), Some("2026-10-18T19:00:00Z"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(edited.render())
|
||||
.contains("AllowedIPs = 10.0.0.7/32, fd00::7/128")
|
||||
);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"same alias"
|
||||
);
|
||||
assert!(original.peers().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_add_preserves_existing_bytes_and_allows_duplicate_aliases() {
|
||||
let source = format!(
|
||||
"# untouched\r\n[Interface]\r\nPrivateKey = hidden\r\n\r\n[Peer]\r\nPublicKey = {KEY_A}\r\nAllowedIPs = 10.0.0.1/32\r\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let mut other = client(KEY_B, "repeated");
|
||||
other.addresses.ipv4 = Ipv4Addr::new(10, 0, 0, 8);
|
||||
let managed = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "repeated", None)
|
||||
.unwrap();
|
||||
let edited = managed.add_client(&other, None).unwrap();
|
||||
let rendered = String::from_utf8(edited.render().to_vec()).unwrap();
|
||||
let peer_header = source.find("[Peer]").unwrap();
|
||||
assert!(rendered.starts_with(&source[..peer_header]));
|
||||
assert!(rendered.contains(&source[peer_header..]));
|
||||
assert_eq!(edited.peers().len(), 2);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_A)).unwrap().alias,
|
||||
"repeated"
|
||||
);
|
||||
assert_eq!(
|
||||
edited.project_client(&key(KEY_B)).unwrap().alias,
|
||||
"repeated"
|
||||
);
|
||||
assert!(rendered.contains("\r\n# wg-admin:begin\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_repeated_renames_reparse_spans_and_preserve_creation_time() {
|
||||
let source = format!(
|
||||
"# outside\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\nPresharedKey = secret\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let first = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "first", Some("2024-02-29T12:00:00Z"))
|
||||
.unwrap();
|
||||
let second = first.rename_client(&key(KEY_A), "second").unwrap();
|
||||
let third = second.rename_client(&key(KEY_A), "third").unwrap();
|
||||
let third_text = String::from_utf8(third.render().to_vec()).unwrap();
|
||||
assert_eq!(third.project_client(&key(KEY_A)).unwrap().alias, "third");
|
||||
assert_eq!(third_text.matches("wg-admin:begin").count(), 1);
|
||||
assert!(third_text.contains("2024-02-29T12:00:00Z"));
|
||||
assert!(third_text.starts_with("# outside\n"));
|
||||
assert!(third_text.ends_with("PresharedKey = secret\n"));
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_rename_and_legacy_insertion_preserve_date_and_unknown_directives() {
|
||||
let source = format!(
|
||||
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\nPresharedKey = secret\n# admin\n"
|
||||
);
|
||||
let original = doc(&source);
|
||||
let added = original
|
||||
.insert_legacy_metadata(&key(KEY_A), "old", Some("2024-01-02T03:04:05Z"))
|
||||
.unwrap();
|
||||
let renamed = added.rename_client(&key(KEY_A), "new # café").unwrap();
|
||||
let view = renamed.project_client(&key(KEY_A)).unwrap();
|
||||
assert_eq!(view.alias, "new # café");
|
||||
assert!(String::from_utf8_lossy(renamed.render()).contains("2024-01-02T03:04:05Z"));
|
||||
assert!(
|
||||
String::from_utf8_lossy(renamed.render()).contains("PresharedKey = secret\n# admin\n")
|
||||
);
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_delete_preserves_admin_and_neighbor_managed_comments() {
|
||||
let block_a = render_block("one", None).unwrap();
|
||||
let block_b = render_block("two", None).unwrap();
|
||||
let source = format!(
|
||||
"# before\n{block_a}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n# keep note\n{block_b}[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.2/32\n"
|
||||
);
|
||||
let edited = doc(&source).delete_client(&key(KEY_A)).unwrap();
|
||||
let output = String::from_utf8(edited.render().to_vec()).unwrap();
|
||||
assert!(!output.contains(KEY_A));
|
||||
assert!(output.contains("# before\n"));
|
||||
assert!(output.contains("# keep note\n"));
|
||||
assert!(output.contains(&block_b));
|
||||
assert_eq!(edited.project_client(&key(KEY_B)).unwrap().alias, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_rejects_ambiguous_edits_and_unsupported_projection_without_mutation() {
|
||||
let source =
|
||||
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32, 10.0.0.2/32\n");
|
||||
let original = doc(&source);
|
||||
assert_eq!(
|
||||
original.project_client(&key(KEY_A)),
|
||||
Err(EditError::MissingAlias)
|
||||
);
|
||||
assert!(matches!(
|
||||
original.delete_client(&key(KEY_B)),
|
||||
Err(EditError::MissingIdentity)
|
||||
));
|
||||
let duplicate = doc(&format!(
|
||||
"{source}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n"
|
||||
));
|
||||
assert!(matches!(
|
||||
duplicate.rename_client(&key(KEY_A), "renamed"),
|
||||
Err(EditError::AmbiguousIdentity)
|
||||
));
|
||||
assert_eq!(original.render(), source.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_projection_supports_ipv4_only_and_rejects_ipv6_only_or_default_routes() {
|
||||
let ipv4 = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.1/32\n",
|
||||
render_block("v4", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
ipv4.project_client(&key(KEY_A)).unwrap().addresses.ipv6,
|
||||
None
|
||||
);
|
||||
let ipv6 = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::1/128\n",
|
||||
render_block("v6", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
ipv6.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses)
|
||||
);
|
||||
let full = doc(&format!(
|
||||
"{}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 0.0.0.0/0\n",
|
||||
render_block("full", None).unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
full.project_client(&key(KEY_A)),
|
||||
Err(EditError::UnsupportedAddresses)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Versioned, comment-only metadata immediately attached to a WireGuard peer.
|
||||
//!
|
||||
//! The alias is encoded as canonical unpadded base64url of its UTF-8 bytes. This
|
||||
//! keeps arbitrary valid aliases on one comment line without introducing config
|
||||
//! syntax, comment termination, or line injection.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
|
||||
use crate::domain::validate_alias;
|
||||
|
||||
use super::SourceSpan;
|
||||
|
||||
const BEGIN: &str = "# wg-admin:begin";
|
||||
const END: &str = "# wg-admin:end";
|
||||
const VERSION_PREFIX: &str = "# wg-admin:version=";
|
||||
const ALIAS_PREFIX: &str = "# wg-admin:alias=";
|
||||
const CREATED_PREFIX: &str = "# wg-admin:created-utc=";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedMetadata {
|
||||
pub version: u32,
|
||||
pub alias: String,
|
||||
pub created_utc: Option<String>,
|
||||
}
|
||||
|
||||
/// A safe half-open source span for a validated, uniquely attached block.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MetadataAttachment {
|
||||
Absent,
|
||||
Valid {
|
||||
metadata: ManagedMetadata,
|
||||
block: SourceSpan,
|
||||
},
|
||||
Invalid(MetadataIssue),
|
||||
}
|
||||
|
||||
/// Redacted metadata problems. Variants identify a line, never include its text.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum MetadataIssue {
|
||||
MalformedBlock { line: usize },
|
||||
UnsupportedVersion { line: usize },
|
||||
InvalidAlias { line: usize },
|
||||
InvalidTimestamp { line: usize },
|
||||
UnknownField { line: usize },
|
||||
MissingField { line: usize },
|
||||
DuplicateBlocks { line: usize, count: usize },
|
||||
Misattached { line: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Line<'a> {
|
||||
content: &'a str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
number: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Candidate {
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
span: SourceSpan,
|
||||
issue: Option<MetadataIssue>,
|
||||
}
|
||||
|
||||
/// Inspect all reserved marker pairs and attach blocks only when their final byte
|
||||
/// is the peer header's first byte. Administrator comments outside this namespace
|
||||
/// are ignored completely.
|
||||
pub(super) fn inspect_document(
|
||||
source: &str,
|
||||
peer_headers: &[usize],
|
||||
) -> (Vec<MetadataAttachment>, Vec<MetadataIssue>) {
|
||||
let lines = lines(source);
|
||||
let mut candidates = Vec::new();
|
||||
let mut issues = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < lines.len() {
|
||||
if marker(lines[index].content) != Some(Marker::Begin) {
|
||||
if marker(lines[index].content) == Some(Marker::End) {
|
||||
issues.push(MetadataIssue::MalformedBlock {
|
||||
line: lines[index].number,
|
||||
});
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index;
|
||||
let mut end = None;
|
||||
let mut malformed = false;
|
||||
let mut cursor = index + 1;
|
||||
while cursor < lines.len() {
|
||||
match marker(lines[cursor].content) {
|
||||
Some(Marker::Begin) => {
|
||||
malformed = true;
|
||||
break;
|
||||
}
|
||||
Some(Marker::End) => {
|
||||
end = Some(cursor);
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
let attached_peer = peer_headers.iter().find(|header| {
|
||||
**header > lines[start].start
|
||||
&& (end.is_none()
|
||||
|| **header <= lines[cursor.min(lines.len().saturating_sub(1))].start)
|
||||
});
|
||||
let block_end_line = end.unwrap_or_else(|| {
|
||||
if malformed {
|
||||
cursor
|
||||
} else if let Some(header) = attached_peer {
|
||||
lines
|
||||
.partition_point(|line| line.start < *header)
|
||||
.saturating_sub(1)
|
||||
} else {
|
||||
lines.len().saturating_sub(1)
|
||||
}
|
||||
});
|
||||
let span_end = if let Some(end_line) = end {
|
||||
lines[end_line].end
|
||||
} else if malformed {
|
||||
lines[block_end_line].start
|
||||
} else if let Some(header) = attached_peer {
|
||||
*header
|
||||
} else {
|
||||
source.len()
|
||||
};
|
||||
let Some(span) = SourceSpan::from_offsets(lines[start].start, span_end) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let issue = if malformed || end.is_none() {
|
||||
Some(MetadataIssue::MalformedBlock {
|
||||
line: lines[start].number,
|
||||
})
|
||||
} else {
|
||||
parse_block(&lines[start..=block_end_line]).err()
|
||||
};
|
||||
if let Some(issue) = &issue {
|
||||
issues.push(issue.clone());
|
||||
}
|
||||
candidates.push(Candidate {
|
||||
start_line: start,
|
||||
end_line: block_end_line,
|
||||
span,
|
||||
issue,
|
||||
});
|
||||
if let Some(end) = end {
|
||||
index = end + 1;
|
||||
} else if malformed {
|
||||
index = cursor;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut attachments = Vec::with_capacity(peer_headers.len());
|
||||
for header in peer_headers {
|
||||
let ending_here: Vec<_> = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, candidate)| candidate.span.end() == *header)
|
||||
.map(|(candidate, _)| candidate)
|
||||
.collect();
|
||||
if ending_here.is_empty() {
|
||||
attachments.push(MetadataAttachment::Absent);
|
||||
continue;
|
||||
}
|
||||
let Some(last) = ending_here.last().copied() else {
|
||||
attachments.push(MetadataAttachment::Absent);
|
||||
continue;
|
||||
};
|
||||
let mut contiguous = 1;
|
||||
let mut previous = last;
|
||||
while previous > 0
|
||||
&& candidates[previous - 1].span.end() == candidates[previous].span.start()
|
||||
{
|
||||
contiguous += 1;
|
||||
previous -= 1;
|
||||
}
|
||||
let candidate = &candidates[last];
|
||||
if contiguous > 1 {
|
||||
let issue = MetadataIssue::DuplicateBlocks {
|
||||
line: lines[candidate.start_line].number,
|
||||
count: contiguous,
|
||||
};
|
||||
issues.push(issue.clone());
|
||||
attachments.push(MetadataAttachment::Invalid(issue));
|
||||
} else if let Some(issue) = &candidate.issue {
|
||||
attachments.push(MetadataAttachment::Invalid(issue.clone()));
|
||||
} else {
|
||||
let parsed = parse_block(&lines[candidate.start_line..=candidate.end_line]);
|
||||
match parsed {
|
||||
Ok(metadata) => attachments.push(MetadataAttachment::Valid {
|
||||
metadata,
|
||||
block: candidate.span,
|
||||
}),
|
||||
Err(issue) => {
|
||||
attachments.push(MetadataAttachment::Invalid(issue));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in &candidates {
|
||||
if !peer_headers.contains(&candidate.span.end()) {
|
||||
let issue = MetadataIssue::Misattached {
|
||||
line: lines[candidate.start_line].number,
|
||||
};
|
||||
issues.push(issue);
|
||||
}
|
||||
}
|
||||
(attachments, issues)
|
||||
}
|
||||
|
||||
/// Render the canonical LF-terminated v1 block. The caller may adapt line endings
|
||||
/// when inserting it into a document; this function performs no source mutation.
|
||||
pub fn render_block(alias: &str, created_utc: Option<&str>) -> Result<String, MetadataError> {
|
||||
validate_alias(alias).map_err(|_| MetadataError::InvalidAlias)?;
|
||||
if let Some(timestamp) = created_utc
|
||||
&& !valid_utc_timestamp(timestamp)
|
||||
{
|
||||
return Err(MetadataError::InvalidTimestamp);
|
||||
}
|
||||
let encoded = URL_SAFE_NO_PAD.encode(alias.as_bytes());
|
||||
let mut block = format!("{BEGIN}\n{VERSION_PREFIX}1\n{ALIAS_PREFIX}{encoded}\n");
|
||||
if let Some(timestamp) = created_utc {
|
||||
block.push_str(CREATED_PREFIX);
|
||||
block.push_str(timestamp);
|
||||
block.push('\n');
|
||||
}
|
||||
block.push_str(END);
|
||||
block.push('\n');
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub enum MetadataError {
|
||||
#[error("client alias is invalid")]
|
||||
InvalidAlias,
|
||||
#[error("creation timestamp must be a valid UTC timestamp")]
|
||||
InvalidTimestamp,
|
||||
}
|
||||
|
||||
fn parse_block(lines: &[Line<'_>]) -> Result<ManagedMetadata, MetadataIssue> {
|
||||
let first_line = lines.first().map_or(1, |line| line.number);
|
||||
if lines.len() < 4 || lines[0].content != BEGIN || lines[lines.len() - 1].content != END {
|
||||
return Err(MetadataIssue::MalformedBlock { line: first_line });
|
||||
}
|
||||
let version_line = &lines[1];
|
||||
let Some(version_text) = version_line.content.strip_prefix(VERSION_PREFIX) else {
|
||||
return Err(MetadataIssue::MissingField { line: first_line });
|
||||
};
|
||||
if version_text.is_empty() || !version_text.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(MetadataIssue::MalformedBlock {
|
||||
line: version_line.number,
|
||||
});
|
||||
}
|
||||
let version = version_text
|
||||
.parse::<u32>()
|
||||
.map_err(|_| MetadataIssue::MalformedBlock {
|
||||
line: version_line.number,
|
||||
})?;
|
||||
if version != 1 {
|
||||
return Err(MetadataIssue::UnsupportedVersion {
|
||||
line: version_line.number,
|
||||
});
|
||||
}
|
||||
let alias_line = &lines[2];
|
||||
let Some(encoded_alias) = alias_line.content.strip_prefix(ALIAS_PREFIX) else {
|
||||
return Err(MetadataIssue::MissingField {
|
||||
line: alias_line.number,
|
||||
});
|
||||
};
|
||||
let decoded =
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(encoded_alias)
|
||||
.map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
if URL_SAFE_NO_PAD.encode(&decoded) != encoded_alias {
|
||||
return Err(MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
});
|
||||
}
|
||||
let alias = String::from_utf8(decoded).map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
validate_alias(&alias).map_err(|_| MetadataIssue::InvalidAlias {
|
||||
line: alias_line.number,
|
||||
})?;
|
||||
let created_lines = &lines[3..lines.len() - 1];
|
||||
let created_utc = match created_lines {
|
||||
[] => None,
|
||||
[line] => {
|
||||
let Some(value) = line.content.strip_prefix(CREATED_PREFIX) else {
|
||||
return Err(if line.content.starts_with("# wg-admin:") {
|
||||
MetadataIssue::UnknownField { line: line.number }
|
||||
} else {
|
||||
MetadataIssue::MalformedBlock { line: line.number }
|
||||
});
|
||||
};
|
||||
if !valid_utc_timestamp(value) {
|
||||
return Err(MetadataIssue::InvalidTimestamp { line: line.number });
|
||||
}
|
||||
Some(value.to_owned())
|
||||
}
|
||||
[line, ..] => {
|
||||
return Err(if line.content.starts_with("# wg-admin:") {
|
||||
MetadataIssue::UnknownField { line: line.number }
|
||||
} else {
|
||||
MetadataIssue::MalformedBlock { line: line.number }
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(ManagedMetadata {
|
||||
version,
|
||||
alias,
|
||||
created_utc,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Marker {
|
||||
Begin,
|
||||
End,
|
||||
}
|
||||
|
||||
fn marker(line: &str) -> Option<Marker> {
|
||||
if line == BEGIN
|
||||
|| line.starts_with(&format!("{BEGIN} "))
|
||||
|| line.starts_with(&format!("{BEGIN}\t"))
|
||||
{
|
||||
Some(Marker::Begin)
|
||||
} else if line == END
|
||||
|| line.starts_with(&format!("{END} "))
|
||||
|| line.starts_with(&format!("{END}\t"))
|
||||
{
|
||||
Some(Marker::End)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Vec<Line<'_>> {
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
for (index, raw) in source.split_inclusive('\n').enumerate() {
|
||||
let end = offset + raw.len();
|
||||
let content = raw.strip_suffix('\n').unwrap_or(raw);
|
||||
let content = content.strip_suffix('\r').unwrap_or(content);
|
||||
result.push(Line {
|
||||
content,
|
||||
start: offset,
|
||||
end,
|
||||
number: index + 1,
|
||||
});
|
||||
offset = end;
|
||||
}
|
||||
if source.is_empty() {
|
||||
return result;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn valid_utc_timestamp(value: &str) -> bool {
|
||||
if value.len() != 20
|
||||
|| !value.is_ascii()
|
||||
|| value.as_bytes()[4] != b'-'
|
||||
|| value.as_bytes()[7] != b'-'
|
||||
|| value.as_bytes()[10] != b'T'
|
||||
|| value.as_bytes()[13] != b':'
|
||||
|| value.as_bytes()[16] != b':'
|
||||
|| value.as_bytes()[19] != b'Z'
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let digits = |range: std::ops::Range<usize>| value[range].parse::<u32>().ok();
|
||||
let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = (
|
||||
digits(0..4),
|
||||
digits(5..7),
|
||||
digits(8..10),
|
||||
digits(11..13),
|
||||
digits(14..16),
|
||||
digits(17..19),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if year == 0 || !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
|
||||
return false;
|
||||
}
|
||||
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
let days = match month {
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
};
|
||||
(1..=days).contains(&day)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_round_trips_aliases_and_optional_timestamp() {
|
||||
let alias = "# café = \"laptop\" <tag>";
|
||||
for timestamp in [None, Some("2024-02-29T23:59:59Z")] {
|
||||
let rendered = render_block(alias, timestamp).unwrap();
|
||||
let header = rendered.len();
|
||||
let (attachments, issues) = inspect_document(
|
||||
&format!(
|
||||
"{rendered}[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"
|
||||
),
|
||||
&[header],
|
||||
);
|
||||
assert!(issues.is_empty());
|
||||
let MetadataAttachment::Valid { metadata, block } = &attachments[0] else {
|
||||
panic!("canonical metadata should inspect successfully");
|
||||
};
|
||||
assert_eq!(metadata.alias, alias);
|
||||
assert_eq!(metadata.created_utc.as_deref(), timestamp);
|
||||
assert_eq!(block.end(), header);
|
||||
assert_eq!(block.start(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_rejects_control_aliases_and_invalid_utc_dates() {
|
||||
assert_eq!(
|
||||
render_block("bad\n# wg-admin:end", None),
|
||||
Err(MetadataError::InvalidAlias)
|
||||
);
|
||||
for timestamp in [
|
||||
"2023-02-29T12:00:00Z",
|
||||
"2024-13-01T12:00:00Z",
|
||||
"2024-01-01T24:00:00Z",
|
||||
"2024-01-01T12:00:60Z",
|
||||
"2024-01-01T12:00:00+00:00",
|
||||
] {
|
||||
assert_eq!(
|
||||
render_block("ok", Some(timestamp)),
|
||||
Err(MetadataError::InvalidTimestamp)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_reports_unsupported_and_misattached_blocks_redacted() {
|
||||
let source = "# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2s\n# wg-admin:end\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, issues) = inspect_document(source, &[header]);
|
||||
assert!(matches!(attachments[0], MetadataAttachment::Absent));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, MetadataIssue::Misattached { .. }))
|
||||
);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }))
|
||||
);
|
||||
assert!(!format!("{issues:?}").contains("b2s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_missing_end_marker_still_attaches_as_invalid() {
|
||||
let source = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2s\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, _) = inspect_document(source, &[header]);
|
||||
assert!(matches!(
|
||||
attachments[0],
|
||||
MetadataAttachment::Invalid(MetadataIssue::MalformedBlock { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_metadata_ignores_unrelated_admin_comments_and_rejects_duplicate_blocks() {
|
||||
let first = render_block("one", None).unwrap();
|
||||
let second = render_block("two", None).unwrap();
|
||||
let source = format!(
|
||||
"# administrator note: wg-admin is not mentioned as an owner\n{first}{second}[Peer]\n"
|
||||
);
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, _) = inspect_document(&source, &[header]);
|
||||
assert!(matches!(
|
||||
attachments[0],
|
||||
MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. })
|
||||
));
|
||||
let source = "# administrator note: wg-admin is not mentioned as an owner\n[Peer]\n";
|
||||
let header = source.find("[Peer]").unwrap();
|
||||
let (attachments, issues) = inspect_document(source, &[header]);
|
||||
assert!(matches!(attachments[0], MetadataAttachment::Absent));
|
||||
assert!(issues.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user