use std::borrow::Cow;
use std::fmt::{self, Display};
pub const NONE: [&str; 0] = [];
enum Item {
Directive(Vec<String>),
Block(Block),
Comment(String),
}
pub struct Block {
head: Vec<String>,
items: Vec<Item>,
}
impl Block {
pub fn file() -> Self {
Self::from_head(Vec::new())
}
pub fn new(name: &str, args: impl IntoIterator<Item = impl Display>) -> Self {
Self::from_head(words(name, args))
}
pub fn exact(path: impl Display) -> Self {
Self::new("location", ["=".to_string(), path.to_string()])
}
pub fn when(left: &str, op: &str, right: impl Display) -> Self {
let right = right.to_string();
let right = quote(&right);
Self::from_head(vec![
"if".into(),
format!("({left}"),
op.into(),
format!("{right})"),
])
}
pub fn directive(mut self, name: &str, args: impl IntoIterator<Item = impl Display>) -> Self {
self.items.push(Item::Directive(words(name, args)));
self
}
pub fn entries(mut self, rows: impl IntoIterator<Item = (impl Display, impl Display)>) -> Self {
for (key, value) in rows {
self.items.push(Item::Directive(words(key, [value])));
}
self
}
pub fn block(mut self, block: Block) -> Self {
self.items.push(Item::Block(block));
self
}
pub fn blocks(self, blocks: impl IntoIterator<Item = Block>) -> Self {
blocks.into_iter().fold(self, Block::block)
}
pub fn comment(mut self, text: &str) -> Self {
self.items.push(Item::Comment(text.into()));
self
}
fn from_head(head: Vec<String>) -> Self {
Self {
head,
items: Vec::new(),
}
}
}
fn words(name: impl Display, args: impl IntoIterator<Item = impl Display>) -> Vec<String> {
std::iter::once(name.to_string())
.chain(args.into_iter().map(|arg| arg.to_string()))
.map(|word| quote(&word).into_owned())
.collect()
}
/// nginx's config read buffer, `NGX_CONF_BUFFER` in `ngx_conf_file.c`.
const CONF_BUFFER: usize = 4096;
/// nginx's lexer needs the word, its closing quote and the byte after them in one read buffer.
/// otherwise it fails with "too long parameter".
pub fn fits(word: &str) -> bool {
match quote(word) {
Cow::Borrowed(bare) => bare.len() + 1 < CONF_BUFFER,
Cow::Owned(quoted) => quoted.len() < CONF_BUFFER,
}
}
/// `$` stays bare, nginx has no escape for it.
fn quote(word: &str) -> Cow<'_, str> {
let plain =
!word.is_empty() && !word.contains(|c: char| c.is_whitespace() || ";{}\"'\\#".contains(c));
if plain {
return Cow::Borrowed(word);
}
let mut out = String::with_capacity(word.len() + 2);
out.push('"');
for c in word.chars() {
match c {
'"' | '\\' => {
out.push('\\');
out.push(c);
}
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out.push('"');
Cow::Owned(out)
}
impl Display for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.head.is_empty() {
write_items(f, &self.items, 0, true)
} else {
write_block(f, self, 0)
}
}
}
fn write_block(f: &mut fmt::Formatter<'_>, block: &Block, depth: usize) -> fmt::Result {
let pad = " ".repeat(depth);
writeln!(f, "{pad}{} {{", block.head.join(" "))?;
write_items(f, &block.items, depth + 1, block.head[0] != "location")?;
writeln!(f, "{pad}}}")
}
fn write_items(
f: &mut fmt::Formatter<'_>,
items: &[Item],
depth: usize,
spaced: bool,
) -> fmt::Result {
let pad = " ".repeat(depth);
for (n, item) in items.iter().enumerate() {
if n > 0 && spaced {
let previous = &items[n - 1];
let touches_block =
matches!(previous, Item::Block(_)) || matches!(item, Item::Block(_));
if touches_block && !matches!(previous, Item::Comment(_)) {
writeln!(f)?;
}
}
match item {
Item::Directive(words) => writeln!(f, "{pad}{};", words.join(" "))?,
Item::Comment(text) => writeln!(f, "{pad}# {text}")?,
Item::Block(block) => write_block(f, block, depth)?,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words_are_quoted_only_when_nginx_would_misread_them() {
assert_eq!(quote("plain/$var:1,2=~^x"), "plain/$var:1,2=~^x");
assert_eq!(quote(""), "\"\"");
assert_eq!(quote("a b"), "\"a b\"");
assert_eq!(quote("a;b"), "\"a;b\"");
assert_eq!(quote("a{4}"), "\"a{4}\"");
assert_eq!(quote("#x"), "\"#x\"");
assert_eq!(quote("say \"hi\\\""), "\"say \\\"hi\\\\\\\"\"");
assert_eq!(quote("it's"), "\"it's\"");
assert_eq!(quote("a\r\n\tb"), "\"a\\r\\n\\tb\"");
assert_eq!(quote("$a b"), "\"$a b\"");
}
/// `nginx -t` reads at most 4094 bytes bare or 4093 quoted.
#[test]
fn fits_stops_at_the_longest_word_nginx_reads() {
assert!(fits(&"a".repeat(4094)));
assert!(!fits(&"a".repeat(4095)));
assert!(fits(&format!("{} ", "a".repeat(4092))));
assert!(!fits(&format!("{} ", "a".repeat(4093))));
assert!(fits(&format!("{}\n", "a".repeat(4091))));
assert!(!fits(&format!("{}\n", "a".repeat(4092))));
assert!(fits(&format!("{}\\", "a".repeat(4091))));
assert!(!fits(&format!("{}\\", "a".repeat(4092))));
}
#[test]
fn blocks_are_spaced_apart_except_inside_a_location() {
let conf = Block::file().directive("pid", ["/run/a b.pid"]).block(
Block::new("server", NONE)
.comment("routes")
.block(
Block::new("location", ["=", "/x"])
.directive("return", ["200", "{\"a\":1}"])
.block(Block::when("$m", "=", "").directive("return", ["400"])),
)
.block(Block::new("location", ["/"]).directive("internal", NONE)),
);
assert_eq!(
conf.to_string(),
"pid \"/run/a b.pid\";\n\
\n\
server {\n\
\x20 # routes\n\
\x20 location = /x {\n\
\x20 return 200 \"{\\\"a\\\":1}\";\n\
\x20 if ($m = \"\") {\n\
\x20 return 400;\n\
\x20 }\n\
\x20 }\n\
\n\
\x20 location / {\n\
\x20 internal;\n\
\x20 }\n\
}\n"
);
}
}