Add server configuration editing with byte preservation

This commit is contained in:
2026-10-18 22:34:01 +03:00
parent c2b899dd1e
commit 766059e6d1
11 changed files with 2521 additions and 19 deletions
+1
View File
@@ -1,3 +1,4 @@
pub mod config;
pub mod domain;
pub mod startup;
pub mod wireguard;
+16
View File
@@ -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};
+630
View File
@@ -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());
}
}
+534
View File
@@ -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)
);
}
}
+501
View File
@@ -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());
}
}