33
44use std:: path:: { Path , PathBuf } ;
55use std:: fs;
6- use std:: collections:: HashMap ;
6+ use std:: collections:: { HashMap , HashSet } ;
77use serde:: { Deserialize , Serialize } ;
88use 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) {
11441184pub 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