//! 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 { 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, /// Every valid public key value, even when the section is ambiguous. pub public_key_candidates: Vec, /// Every PublicKey directive is retained as a span, including duplicates and malformed values. pub public_key_fields: Vec, /// Parsed route values. Unparseable items are represented by `PeerIssue` entries. pub allowed_ips: Vec, pub allowed_ips_fields: Vec, /// Parsed alias and safe owned block span, or an explicit metadata issue. pub metadata: MetadataAttachment, pub issues: Vec, } /// 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 }, 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, peers: Vec, issues: Vec, } 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 { let bytes = source.as_ref(); let source = std::str::from_utf8(bytes).map_err(|_| DocumentError::InvalidEncoding)?; let mut sections = Vec::::new(); let mut issues = Vec::::new(); let mut peer_fields = Vec::<( usize, Vec, Vec<(DirectiveSpan, Vec<(usize, Result)>)>, )>::new(); let mut current_section: Option = 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::>::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()); } }