//! 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, } /// 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, } /// 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, Vec) { 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 { 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 { 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::() .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 { 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> { 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| value[range].parse::().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\" "; 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()); } }