502 lines
17 KiB
Rust
502 lines
17 KiB
Rust
//! 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());
|
|
}
|
|
}
|