crates/wg-appliance-core/src/ini.rs
//! The `wg-quick(8)` INI **tokenizer** — and nothing above it.
//!
//! Both appliances read the same on-disk file format (`/etc/wireguard/wg0.conf`)
//! and both were writing the same thirty lines to take it apart: strip a `#` or
//! `;` comment, skip blanks, recognise `[Section]`, split `Key = value` on the
//! FIRST `=`, lowercase the key, keep the 1-based line number for the error
//! message. That is what is shared.
//!
//! What is **not** shared is what a token MEANS. gunnar refuses `DNS`, `MTU`,
//! `Table` and `PostUp` by name and requires `PrivateKey`/`Address`/a peer with
//! `AllowedIPs`; tunnr's payload accepts `DNS`/`MTU`/IPv6/multiple addresses and
//! infers a server-or-client role. A tokenizer that tried to be both would be a
//! third policy neither product declares, so the policy stays in each consumer
//! and only the scanner moved.
//!
//! `Item`'s `Debug` MASKS a `PrivateKey`/`PresharedKey` value: the token IS the
//! key material, and a derive put it one `{:?}` from every log line.
//!
//! The tokenizer never fails: a line that is not blank, not a comment, not a
//! section and not `Key = value` comes out as [`Item::Malformed`] carrying its
//! own text, so each consumer raises the error IT has already declared (gunnar's
//! `wg0.conf:{line}: … is not \`Key = value\``, tunnr's `ParseError::Malformed`)
//! rather than translating a foreign one.
use crate::keys::REDACTED;
/// The key names whose VALUE is secret, lowercased the way [`items`] folds a
/// key. `wg-quick(8)` has exactly two.
const SECRET_KEYS: [&str; 2] = ["privatekey", "presharedkey"];
/// Does this already-lowercased text BEGIN with a secret key name?
///
/// `starts_with` rather than `==` on purpose: a padded WireGuard key ends in
/// `=`, so a line that lost its `=` to a space (`PrivateKey gEi6…WHg=`) splits
/// on that trailing padding and the whole secret lands in [`Item::Pair::key`].
/// The same test answers for [`Item::Malformed`], where the secret is in the
/// text.
fn is_secret(lowercased: &str) -> bool {
SECRET_KEYS.iter().any(|k| lowercased.starts_with(k))
}
/// One thing found on one line.
///
/// `Debug` is hand-written and MASKS the value of `PrivateKey` and
/// `PresharedKey` — see the impl for why the derive was wrong.
#[derive(Clone, PartialEq, Eq)]
pub enum Item<'a> {
/// A `[Section]` header. `name` is trimmed and lowercased; `raw_name` is
/// what was actually written, for an error message that quotes the file.
Section {
/// 1-based line number.
line: usize,
/// The header, trimmed and lowercased (`interface`, `peer`, …).
name: String,
/// The header exactly as written between the brackets.
raw_name: &'a str,
},
/// A `Key = value` pair.
Pair {
/// 1-based line number.
line: usize,
/// The key, trimmed and lowercased (`privatekey`, `allowedips`, …).
key: String,
/// The value, trimmed, with any comment already removed.
value: &'a str,
},
/// A line that is none of the above. Reported rather than skipped: a
/// silently dropped line is a setting the operator believes took effect.
Malformed {
/// 1-based line number.
line: usize,
/// The line, comment-stripped and trimmed.
text: &'a str,
},
}
impl std::fmt::Debug for Item<'_> {
/// `Item` DERIVED `Debug`, and an `Item::Pair` off a `PrivateKey` line
/// carries the appliance's private key as its `value`, verbatim — so one
/// `{:?}` on a token stream printed the identity. That is not a
/// hypothetical: this module's own first test renders `{got:?}` in an
/// assertion message, and a consumer that logs the items it did not
/// understand does the same. `PresharedKey` likewise.
///
/// Every arm is masked by the SAME predicate ([`is_secret`]), so the two
/// shapes a mistyped key line takes are covered by construction rather
/// than by remembering them: the value when the line parsed, the KEY when
/// a padded key's trailing `=` became the separator, and the whole text
/// when there was no `=` at all. The line number and the key NAME still
/// print — they are what an operator needs to find the line.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Item::Section { line, name, raw_name } => f
.debug_struct("Section")
.field("line", line)
.field("name", name)
.field("raw_name", raw_name)
.finish(),
Item::Pair { line, key, value } => {
let secret = is_secret(key);
f.debug_struct("Pair")
.field("line", line)
// A secret-bearing KEY is the `=`-lost-to-a-space case: the
// name is safe, everything after it is the key material.
.field(
"key",
&if secret {
let name = SECRET_KEYS.iter().find(|k| key.starts_with(*k)).unwrap();
if key.len() > name.len() {
format!("{name} {REDACTED}")
} else {
(*name).to_string()
}
} else {
key.clone()
},
)
.field("value", &if secret { REDACTED } else { value })
.finish()
}
Item::Malformed { line, text } => f
.debug_struct("Malformed")
.field("line", line)
.field(
"text",
&if is_secret(&text.to_ascii_lowercase()) { REDACTED } else { text },
)
.finish(),
}
}
}
impl Item<'_> {
/// The 1-based line this item came from.
pub fn line(&self) -> usize {
match self {
Item::Section { line, .. } | Item::Pair { line, .. } | Item::Malformed { line, .. } => {
*line
}
}
}
}
/// Tokenize a wg-quick config. Blank and comment-only lines yield nothing.
///
/// Lazily evaluated and borrowing `text`, so a consumer that bails on the first
/// error never scans the rest of the file.
pub fn items(text: &str) -> impl Iterator<Item = Item<'_>> {
text.lines().enumerate().filter_map(|(idx, raw)| {
let line = idx + 1;
// `#` and `;` both start a comment in wg-quick's own parser, and both
// may appear after a value (`PersistentKeepalive = 25 ; keep NAT open`).
let body = raw.split(['#', ';']).next().unwrap_or("").trim();
if body.is_empty() {
return None;
}
if let Some(inner) = body.strip_prefix('[') {
// A missing `]` is tolerated the same way both consumers already
// tolerated it: the header is what is inside the brackets, and an
// unknown name is the consumer's error to raise.
let raw_name = inner.strip_suffix(']').unwrap_or(inner).trim();
return Some(Item::Section {
line,
name: raw_name.to_ascii_lowercase(),
raw_name,
});
}
match body.split_once('=') {
Some((key, value)) => Some(Item::Pair {
line,
key: key.trim().to_ascii_lowercase(),
value: value.trim(),
}),
None => Some(Item::Malformed { line, text: body }),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# tunnel
[Interface]
PrivateKey = yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
Address = 10.0.0.2/32, fd00::2/128
[Peer]
PublicKey = xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
PersistentKeepalive = 25 ; keep NAT open
";
#[test]
fn tokenizes_sections_pairs_comments_and_line_numbers() {
let got: Vec<_> = items(SAMPLE).collect();
assert_eq!(
got[0],
Item::Section { line: 2, name: "interface".into(), raw_name: "Interface" },
"the comment on line 1 yields nothing and line 2 is the header"
);
assert!(matches!(&got[1], Item::Pair { line: 3, key, .. } if key == "privatekey"));
// The blank line 5 yields nothing; [Peer] is line 6.
assert_eq!(
got[3],
Item::Section { line: 6, name: "peer".into(), raw_name: "Peer" }
);
// A trailing `;` comment must not end up inside the value.
assert_eq!(
got[5],
Item::Pair { line: 8, key: "persistentkeepalive".into(), value: "25" },
"an inline comment is stripped from the VALUE, not just from the line"
);
assert_eq!(got.len(), 6, "nothing else was emitted: {got:?}");
}
/// The rule a naive `split('=')` gets wrong: a base64 key ends in `=`, and
/// splitting on every `=` would truncate every 32-byte key in the file.
#[test]
fn a_value_keeps_its_base64_padding() {
let got: Vec<_> = items("[Interface]\nPrivateKey = c2hvcnQ=\n").collect();
assert_eq!(
got[1],
Item::Pair { line: 2, key: "privatekey".into(), value: "c2hvcnQ=" },
"the split is on the FIRST `=` only"
);
}
/// LAW 2 — a line that is not `Key = value` must SURFACE. If this returned
/// nothing, a typo'd `PrivateKey` line would read as an absent key and the
/// consumer's "no PrivateKey" error would name the wrong cause.
#[test]
fn a_line_that_is_not_key_equals_value_is_reported_not_dropped() {
let got: Vec<_> = items("[Interface]\nthis line has no equals\n").collect();
assert_eq!(
got[1],
Item::Malformed { line: 2, text: "this line has no equals" }
);
}
/// **A token stream's `Debug` must not contain the private key.**
///
/// `Item` derived `Debug`, and `Item::Pair { value }` IS the base64 private
/// key, verbatim, when the key is `privatekey` — so the `{got:?}` in this
/// module's own first test, and any consumer that logs the items it did
/// not understand, printed the appliance's identity. `PresharedKey` the
/// same. And the two shapes a mistyped line takes, which are the ones a
/// consumer is MOST likely to quote in an error: a padded key always ends
/// in `=`, so `PrivateKey gEi6…WHg=` (the `=` lost to a space) tokenizes
/// as a `Pair` whose KEY is the secret, case-folded; an unpadded one
/// (`PresharedKey mnk…`) has no `=` at all and is `Malformed` whole.
///
/// RED-WHEN-BROKEN: put `Debug` back in `Item`'s derive. Measured against
/// the path copy as published at `0.1.2` on 2026-09-05: the `{:?}` leg
/// failed on "the private key as base64".
#[test]
fn a_token_stream_never_renders_a_secret_value() {
use crate::keys::{decode_key, encode_key, probe, KEY_LEN};
const PRIVATE: &str = "gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=";
const PUBLIC: &str = "R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=";
let psk: [u8; KEY_LEN] = std::array::from_fn(|i| 0x9A ^ i as u8);
let psk_b64 = encode_key(&psk);
let text = format!(
"[Interface]\nPrivateKey = {PRIVATE}\n[Peer]\nPublicKey = {PUBLIC}\n\
PresharedKey = {psk_b64}\nPersistentKeepalive = 25\n\
PrivateKey {PRIVATE}\nPresharedKey {}\n",
psk_b64.trim_end_matches('=')
);
let got: Vec<_> = items(&text).collect();
assert_eq!(got.len(), 8);
assert!(
matches!(&got[6], Item::Pair { line: 7, key, value } if key.starts_with("privatekey g") && value.is_empty()),
"the space-for-`=` line is a Pair whose key IS the secret: {:?}",
got[6].line()
);
assert!(matches!(got[7], Item::Malformed { line: 8, .. }), "the unpadded line has no `=`");
probe::assert_masks(
&[("{:?}", format!("{got:?}")), ("{:#?}", format!("{got:#?}"))],
&[("the private key", decode_key(PRIVATE).unwrap()), ("the preshared key", psk)],
&[PUBLIC, "persistentkeepalive", "25", "Malformed"],
);
}
#[test]
fn keys_and_sections_are_case_folded_but_values_are_not() {
let got: Vec<_> = items("[INTERFACE]\nPRIVATEKEY = AbC\n").collect();
assert!(matches!(&got[0], Item::Section { name, .. } if name == "interface"));
assert_eq!(
got[1],
Item::Pair { line: 2, key: "privatekey".into(), value: "AbC" },
"a key is case-insensitive; a base64 value is NOT"
);
}
}