Skip to content

Commit 8f6b502

Browse files
author
Paul C
committed
v0.4.13: resolve relative includes against the config root, like nginx
wabil reported includes that "were working and now aren't." The cause is a long-standing divergence from nginx: WolfProxy resolved a relative `include` path only against the *including file's* directory, whereas nginx resolves it against the config-root prefix (e.g. /etc/nginx). So the standard pattern of a server block in sites-enabled/ doing `include snippets/ssl.conf;` — written relative to /etc/nginx — resolved to /etc/nginx/sites-enabled/snippets/ssl.conf (missing) and the directives were silently dropped. Fix: thread the config-root prefix through the parser and resolve every relative include against BOTH the including file's dir (historical behaviour) and the prefix (nginx behaviour), at the top level and inside server blocks. Matches are merged and de-duplicated by canonical path, so a config that already worked keeps working and a file reachable both ways is never loaded twice. Purely additive — it can only resolve includes that previously matched nothing, so it can't break an existing install. - include_candidate_paths() builds the [file-dir, prefix] candidate list. - parse_nginx_config_in / parse_nginx_content_in carry the prefix recursively; load_nginx_config + load_nginx_sites pass nginx_dir as the prefix. - expand_server_includes() takes the prefix and de-dups the same way. - 2 tests: a prefix-relative server-block include now resolves; a top-level include reachable via identical base+prefix roots loads exactly once.
1 parent b4d42c4 commit 8f6b502

2 files changed

Lines changed: 172 additions & 73 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfproxy"
3-
version = "0.4.12"
3+
version = "0.4.13"
44
edition = "2021"
55
description = "A Rust-based nginx proxy replacement that reads nginx configuration files"
66
authors = ["Wolf Software Systems Ltd"]

src/nginx.rs

Lines changed: 171 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
44
use std::path::{Path, PathBuf};
55
use std::fs;
6-
use std::collections::HashMap;
6+
use std::collections::{HashMap, HashSet};
77
use serde::{Deserialize, Serialize};
88
use regex::Regex;
99

@@ -248,83 +248,117 @@ pub struct NginxConfig {
248248
}
249249

250250
/// Parse an nginx configuration file
251-
pub fn parse_nginx_config(path: &Path) -> NginxConfig {
251+
/// Read and parse one config file, carrying the config-root `prefix` (the
252+
/// directory of the main config) down through nested includes so relative
253+
/// include paths can be resolved the way nginx does (against the prefix) as well
254+
/// as against the including file's own directory. See `include_candidate_paths`.
255+
fn parse_nginx_config_in(path: &Path, prefix: Option<&Path>) -> NginxConfig {
252256
let content = match fs::read_to_string(path) {
253257
Ok(c) => c,
254258
Err(e) => {
255259
eprintln!("Failed to read nginx config {}: {}", path.display(), e);
256260
return NginxConfig::default();
257261
}
258262
};
259-
260-
parse_nginx_content(&content, path.parent())
263+
264+
parse_nginx_content_in(&content, path.parent(), prefix)
265+
}
266+
267+
/// The filesystem paths a (possibly glob) include should be resolved against:
268+
/// the including file's directory first — wolfproxy's historical behaviour, so a
269+
/// config that already works keeps working — and then the config-root `prefix`,
270+
/// which is how nginx itself resolves a relative include. The two are merged
271+
/// (de-duplicated by their string form) and the caller further de-duplicates the
272+
/// actual matched files by canonical path, so a file reachable both ways is only
273+
/// ever loaded once. An absolute include resolves to just itself.
274+
fn include_candidate_paths(include: &str, base: &Path, prefix: Option<&Path>) -> Vec<PathBuf> {
275+
if include.starts_with('/') {
276+
return vec![PathBuf::from(include)];
277+
}
278+
let mut candidates = vec![base.join(include)];
279+
if let Some(p) = prefix {
280+
let pj = p.join(include);
281+
if !candidates.contains(&pj) {
282+
candidates.push(pj);
283+
}
284+
}
285+
candidates
261286
}
262287

263-
/// Parse nginx configuration content
264-
pub fn parse_nginx_content(content: &str, base_dir: Option<&Path>) -> NginxConfig {
288+
fn parse_nginx_content_in(content: &str, base_dir: Option<&Path>, prefix: Option<&Path>) -> NginxConfig {
265289
let mut config = NginxConfig::default();
266-
290+
267291
// Remove comments but preserve strings
268292
let content = remove_comments(content);
269-
293+
270294
// Parse upstreams
271295
config.upstreams = parse_upstreams(&content);
272-
296+
273297
// Parse server blocks
274-
config.servers = parse_server_blocks(&content, base_dir);
275-
298+
config.servers = parse_server_blocks(&content, base_dir, prefix);
299+
276300
// Parse includes and merge
277301
if let Some(base) = base_dir {
278302
for include in find_includes(&content) {
279-
let include_path = if include.starts_with('/') {
280-
PathBuf::from(&include)
281-
} else {
282-
base.join(&include)
283-
};
284-
303+
let candidates = include_candidate_paths(&include, base, prefix);
304+
285305
// Handle glob patterns
286306
if include.contains('*') {
287-
match glob::glob(&include_path.to_string_lossy()) {
288-
Ok(paths) => {
289-
let mut matched = 0usize;
290-
for entry in paths.flatten() {
291-
let sub_config = parse_nginx_config(&entry);
292-
merge_configs(&mut config, sub_config);
293-
matched += 1;
294-
}
295-
// A glob matching nothing is legal in nginx (not an
296-
// error) but is almost always a mistake — e.g.
297-
// `sites-enabled/*.conf` against Debian's extensionless
298-
// symlinks. Surface it so it isn't silent.
299-
if matched == 0 {
300-
config.include_warnings.push(format!(
301-
"include '{}' matched no files — check the path and that the \
302-
filenames match the pattern (Debian sites-enabled entries often \
303-
have no .conf suffix, so '*.conf' matches nothing; try '*')",
304-
include_path.display()
305-
));
307+
// Merge matches from every candidate root, de-duplicated by
308+
// canonical path so a file reachable both relative-to-file and
309+
// relative-to-prefix isn't loaded (and merged) twice.
310+
let mut seen: HashSet<PathBuf> = HashSet::new();
311+
let mut matched = 0usize;
312+
let mut glob_error: Option<String> = None;
313+
for cand in &candidates {
314+
match glob::glob(&cand.to_string_lossy()) {
315+
Ok(paths) => {
316+
for entry in paths.flatten() {
317+
let key = fs::canonicalize(&entry).unwrap_or_else(|_| entry.clone());
318+
if seen.insert(key) {
319+
let sub_config = parse_nginx_config_in(&entry, prefix);
320+
merge_configs(&mut config, sub_config);
321+
matched += 1;
322+
}
323+
}
306324
}
325+
Err(e) => glob_error = Some(e.to_string()),
326+
}
327+
}
328+
// A glob matching nothing is legal in nginx (not an error) but is
329+
// almost always a mistake — e.g. `sites-enabled/*.conf` against
330+
// Debian's extensionless symlinks. Surface it so it isn't silent.
331+
if matched == 0 {
332+
if let Some(e) = glob_error {
333+
config.include_warnings.push(format!(
334+
"include '{}' is not a valid glob pattern: {}", include, e
335+
));
336+
} else {
337+
config.include_warnings.push(format!(
338+
"include '{}' matched no files — check the path and that the \
339+
filenames match the pattern (Debian sites-enabled entries often \
340+
have no .conf suffix, so '*.conf' matches nothing; try '*')",
341+
include
342+
));
307343
}
308-
Err(e) => config.include_warnings.push(format!(
309-
"include '{}' is not a valid glob pattern: {}", include, e
310-
)),
311344
}
312-
} else if include_path.exists() {
313-
let sub_config = parse_nginx_config(&include_path);
345+
} else if let Some(found) = candidates.iter().find(|c| c.exists()) {
346+
let sub_config = parse_nginx_config_in(found, prefix);
314347
merge_configs(&mut config, sub_config);
315348
} else {
316349
// An explicit (non-glob) include of a file that doesn't exist:
317350
// real nginx treats this as a fatal `-t` error. We surface it
318351
// loudly but don't refuse to load — a proxy that's been running
319352
// shouldn't suddenly fail to start on upgrade (see main.rs).
353+
let looked = candidates.iter().map(|c| c.display().to_string()).collect::<Vec<_>>().join(" or ");
320354
config.include_warnings.push(format!(
321-
"include '{}' refers to a file that does not exist",
322-
include_path.display()
355+
"include '{}' refers to a file that does not exist (looked in {})",
356+
include, looked
323357
));
324358
}
325359
}
326360
}
327-
361+
328362
config
329363
}
330364

@@ -466,7 +500,7 @@ const MAX_SERVER_INCLUDE_DEPTH: usize = 16;
466500
/// `parse_server_directive`, which can't parse nested `location` blocks, and
467501
/// globs were rejected outright — so an `include sites/*.conf;` inside a server
468502
/// silently dropped every directive (wabil 2026-06-13).
469-
fn expand_server_includes(body: &str, base_dir: Option<&Path>, depth: usize) -> String {
503+
fn expand_server_includes(body: &str, base_dir: Option<&Path>, prefix: Option<&Path>, depth: usize) -> String {
470504
if depth >= MAX_SERVER_INCLUDE_DEPTH {
471505
eprintln!(
472506
"wolfproxy: server-block include nesting exceeded {} levels — stopping (possible include cycle)",
@@ -489,30 +523,36 @@ fn expand_server_includes(body: &str, base_dir: Option<&Path>, depth: usize) ->
489523
out.push('\n');
490524
continue;
491525
};
492-
let resolved = if path.starts_with('/') { PathBuf::from(path) } else { base.join(path) };
493-
let mut files: Vec<PathBuf> = if path.contains('*') {
494-
match glob::glob(&resolved.to_string_lossy()) {
495-
Ok(paths) => {
496-
let matched: Vec<PathBuf> = paths.flatten().collect();
497-
// A zero-match glob is legal in nginx (not an error), but
498-
// almost always a mistake — surface it. Reported only here,
499-
// so a glob ERROR (below) doesn't also trip a "no files".
500-
if matched.is_empty() {
501-
eprintln!("wolfproxy: include '{}' inside a server block matched no files", resolved.display());
526+
// Resolve against the including file's dir AND the config prefix (nginx
527+
// semantics), merging matches de-duplicated by canonical path so a file
528+
// reachable both ways is expanded only once.
529+
let candidates = include_candidate_paths(path, base, prefix);
530+
let mut files: Vec<PathBuf> = Vec::new();
531+
let mut seen: HashSet<PathBuf> = HashSet::new();
532+
if path.contains('*') {
533+
let mut glob_error: Option<String> = None;
534+
for cand in &candidates {
535+
match glob::glob(&cand.to_string_lossy()) {
536+
Ok(paths) => {
537+
for entry in paths.flatten() {
538+
let key = fs::canonicalize(&entry).unwrap_or_else(|_| entry.clone());
539+
if seen.insert(key) { files.push(entry); }
540+
}
502541
}
503-
matched
542+
Err(e) => glob_error = Some(e.to_string()),
504543
}
505-
Err(e) => {
506-
eprintln!("wolfproxy: include '{}' inside a server block is not a valid glob: {}", path, e);
507-
Vec::new()
544+
}
545+
if files.is_empty() {
546+
match glob_error {
547+
Some(e) => eprintln!("wolfproxy: include '{}' inside a server block is not a valid glob: {}", path, e),
548+
None => eprintln!("wolfproxy: include '{}' inside a server block matched no files", path),
508549
}
509550
}
510-
} else if resolved.exists() {
511-
vec![resolved.clone()]
551+
} else if let Some(found) = candidates.iter().find(|c| c.exists()) {
552+
files.push(found.clone());
512553
} else {
513-
eprintln!("wolfproxy: include '{}' inside a server block refers to a file that does not exist", resolved.display());
514-
Vec::new()
515-
};
554+
eprintln!("wolfproxy: include '{}' inside a server block refers to a file that does not exist", path);
555+
}
516556
// Deterministic, nginx-like lexical order regardless of glob() ordering.
517557
files.sort();
518558
for f in files {
@@ -538,7 +578,7 @@ fn expand_server_includes(body: &str, base_dir: Option<&Path>, depth: usize) ->
538578
continue;
539579
}
540580
let child_base = f.parent().unwrap_or(base);
541-
out.push_str(&expand_server_includes(&stripped, Some(child_base), depth + 1));
581+
out.push_str(&expand_server_includes(&stripped, Some(child_base), prefix, depth + 1));
542582
if !out.ends_with('\n') { out.push('\n'); }
543583
}
544584
Err(e) => eprintln!("wolfproxy: could not read server include '{}': {}", f.display(), e),
@@ -548,7 +588,7 @@ fn expand_server_includes(body: &str, base_dir: Option<&Path>, depth: usize) ->
548588
out
549589
}
550590

551-
fn parse_server_blocks(content: &str, base_dir: Option<&Path>) -> Vec<ServerBlock> {
591+
fn parse_server_blocks(content: &str, base_dir: Option<&Path>, prefix: Option<&Path>) -> Vec<ServerBlock> {
552592
let mut servers = Vec::new();
553593

554594
// Find server blocks - this is simplified and may need improvement for nested braces
@@ -585,7 +625,7 @@ fn parse_server_blocks(content: &str, base_dir: Option<&Path>) -> Vec<ServerBloc
585625
// End of server block. Expand any `include`s textually first so
586626
// included location/if blocks and directives parse in the server
587627
// context (nginx's textual-include semantics).
588-
let expanded = expand_server_includes(&server_content, base_dir, 0);
628+
let expanded = expand_server_includes(&server_content, base_dir, prefix, 0);
589629
if let Some(server) = parse_server_content(&expanded) {
590630
servers.push(server);
591631
}
@@ -1144,9 +1184,10 @@ fn merge_configs(main: &mut NginxConfig, other: NginxConfig) {
11441184
pub fn load_nginx_config(nginx_dir: &Path) -> NginxConfig {
11451185
let main = nginx_dir.join("root.conf");
11461186
if main.is_file() {
1147-
// parse_nginx_config sets base_dir = nginx_dir, so the main file's
1148-
// includes resolve exactly like nginx, recursively.
1149-
return parse_nginx_config(&main);
1187+
// The config-root prefix is nginx_dir, so a relative include resolves
1188+
// both against the including file's dir and against nginx_dir — exactly
1189+
// like nginx, recursively (see include_candidate_paths).
1190+
return parse_nginx_config_in(&main, Some(nginx_dir));
11501191
}
11511192
// No main config — fall back to auto-scanning sites-enabled/ + conf.d/
11521193
// (the original WolfProxy behaviour; kept so nothing breaks on upgrade).
@@ -1164,7 +1205,9 @@ pub fn load_nginx_sites(nginx_dir: &Path) -> NginxConfig {
11641205
for entry in entries.flatten() {
11651206
let path = entry.path();
11661207
if path.is_file() {
1167-
let sub_config = parse_nginx_config(&path);
1208+
// prefix = nginx_dir so relative includes inside these site
1209+
// files (e.g. `include snippets/ssl.conf;`) resolve like nginx.
1210+
let sub_config = parse_nginx_config_in(&path, Some(nginx_dir));
11681211
merge_configs(&mut config, sub_config);
11691212
}
11701213
}
@@ -1178,7 +1221,7 @@ pub fn load_nginx_sites(nginx_dir: &Path) -> NginxConfig {
11781221
for entry in entries.flatten() {
11791222
let path = entry.path();
11801223
if path.extension().is_some_and(|ext| ext == "conf") {
1181-
let sub_config = parse_nginx_config(&path);
1224+
let sub_config = parse_nginx_config_in(&path, Some(nginx_dir));
11821225
merge_configs(&mut config, sub_config);
11831226
}
11841227
}
@@ -1401,4 +1444,60 @@ mod tests {
14011444
assert!(!srv.locations.iter().any(|l| l.proxy_pass.as_deref().is_some_and(|p| p.contains("6666"))),
14021445
"a server{{}}-containing include must be skipped, not folded into the outer server");
14031446
}
1447+
1448+
#[test]
1449+
fn test_prefix_relative_include_resolves_like_nginx() {
1450+
// wabil 2026-06-14: a relative include written the nginx way — relative
1451+
// to the config ROOT (/etc/nginx), not to the including file's own dir.
1452+
// root.conf pulls in sites/app.conf; that server block does
1453+
// `include snippets/loc.conf;`. The snippets dir lives at the PREFIX
1454+
// (root/snippets), NOT under sites/. Before the prefix-aware resolution
1455+
// this resolved to root/sites/snippets/loc.conf (missing) and the
1456+
// location was silently dropped; now it must load.
1457+
let dir = std::env::temp_dir().join(format!("wp-prefixinc-{}", std::process::id()));
1458+
let sites = dir.join("sites");
1459+
let snippets = dir.join("snippets");
1460+
let _ = fs::remove_dir_all(&dir);
1461+
fs::create_dir_all(&sites).unwrap();
1462+
fs::create_dir_all(&snippets).unwrap();
1463+
fs::write(dir.join("root.conf"), "http {\n include sites/app.conf;\n}\n").unwrap();
1464+
// Server-block include written relative to the prefix, not to sites/.
1465+
fs::write(sites.join("app.conf"),
1466+
"server {\n listen 80;\n server_name app.example.com;\n include snippets/loc.conf;\n}\n").unwrap();
1467+
fs::write(snippets.join("loc.conf"),
1468+
"location /api/ {\n proxy_pass http://127.0.0.1:9000;\n}\n").unwrap();
1469+
1470+
let config = load_nginx_config(&dir);
1471+
let _ = fs::remove_dir_all(&dir);
1472+
let srv = config.servers.iter()
1473+
.find(|s| s.server_names.iter().any(|n| n == "app.example.com"))
1474+
.expect("server app.example.com should parse");
1475+
assert!(srv.locations.iter().any(|l| l.proxy_pass.as_deref().is_some_and(|p| p.contains("9000"))),
1476+
"a prefix-relative server-block include should resolve against the config root; got {:?}",
1477+
srv.locations.iter().map(|l| &l.proxy_pass).collect::<Vec<_>>());
1478+
}
1479+
1480+
#[test]
1481+
fn test_prefix_relative_toplevel_include_no_double_load() {
1482+
// A top-level include reachable BOTH relative-to-file and relative-to-
1483+
// prefix (here the two are the same directory) must load each server
1484+
// exactly once — the de-dup by canonical path guards against that.
1485+
let dir = std::env::temp_dir().join(format!("wp-dedup-{}", std::process::id()));
1486+
let _ = fs::remove_dir_all(&dir);
1487+
fs::create_dir_all(&dir).unwrap();
1488+
// root.conf is in `dir`, and the include is relative — so base_dir (dir)
1489+
// and prefix (dir) resolve to the same files. Must not double-count.
1490+
fs::write(dir.join("root.conf"), "http {\n include parts/*.conf;\n}\n").unwrap();
1491+
let parts = dir.join("parts");
1492+
fs::create_dir_all(&parts).unwrap();
1493+
fs::write(parts.join("one.conf"),
1494+
"server {\n listen 80;\n server_name dedup.example.com;\n}\n").unwrap();
1495+
1496+
let config = load_nginx_config(&dir);
1497+
let _ = fs::remove_dir_all(&dir);
1498+
let count = config.servers.iter()
1499+
.filter(|s| s.server_names.iter().any(|n| n == "dedup.example.com"))
1500+
.count();
1501+
assert_eq!(count, 1, "server reachable via identical base+prefix roots must load once, not {}", count);
1502+
}
14041503
}

0 commit comments

Comments
 (0)