@@ -99,14 +99,32 @@ Commands:
9999// ── build ────────────────────────────────────────────────────────────
100100
101101fn cmd_build ( ) {
102+ if !check_mdbook ( ) {
103+ eprintln ! ( "Error: 'mdbook' not found in PATH. Please install it: https://rust-lang.github.io/mdbook/guide/installation.html" ) ;
104+ std:: process:: exit ( 1 ) ;
105+ }
102106 build_to ( "site" ) ;
103107}
104108
105109fn cmd_deploy ( ) {
110+ if !check_mdbook ( ) {
111+ eprintln ! ( "Error: 'mdbook' not found in PATH." ) ;
112+ std:: process:: exit ( 1 ) ;
113+ }
106114 build_to ( "docs" ) ;
107115 println ! ( "\n To publish, commit docs/ and enable GitHub Pages → \" Deploy from a branch\" → /docs." ) ;
108116}
109117
118+ fn check_mdbook ( ) -> bool {
119+ Command :: new ( "mdbook" )
120+ . arg ( "--version" )
121+ . stdout ( std:: process:: Stdio :: null ( ) )
122+ . stderr ( std:: process:: Stdio :: null ( ) )
123+ . status ( )
124+ . map ( |s| s. success ( ) )
125+ . unwrap_or ( false )
126+ }
127+
110128fn build_to ( dir_name : & str ) {
111129 let root = project_root ( ) ;
112130 let out = root. join ( dir_name) ;
@@ -289,32 +307,68 @@ fn write_landing_page(site: &Path) {
289307 println ! ( " ✓ index.html" ) ;
290308}
291309
310+ enum ResolveResult {
311+ File ( PathBuf ) ,
312+ Redirect ( String ) ,
313+ NotFound ,
314+ }
315+
292316/// Resolve `request_target` (HTTP request path, e.g. `/foo/bar?x=1`) to a file under `site_canon`.
293- /// Returns `None` for traversal attempts, missing files, or paths that escape `site_canon` (symlinks).
294- fn resolve_site_file ( site_canon : & Path , request_target : & str ) -> Option < PathBuf > {
295- let path_only = request_target. split ( '?' ) . next ( ) ?. split ( '#' ) . next ( ) ?;
317+ /// Returns `ResolveResult::File` for success, `Redirect` if a trailing slash is needed for a directory,
318+ /// or `NotFound` for traversal attempts or missing files.
319+ ///
320+ /// NOTE: This function preserves and hardens the multi-layer security from PR#18:
321+ /// 1. Percent-decoding via `percent_decode_path`.
322+ /// 2. Null byte rejection.
323+ /// 3. Traversal blocking (`..`).
324+ /// 4. Symlink escape prevention via canonicalization and prefix checking.
325+ fn resolve_site_file ( site_canon : & Path , request_target : & str ) -> ResolveResult {
326+ let path_only = match request_target
327+ . split ( '?' )
328+ . next ( )
329+ . and_then ( |s| s. split ( '#' ) . next ( ) )
330+ {
331+ Some ( p) => p,
332+ None => return ResolveResult :: NotFound ,
333+ } ;
334+
335+ // [Security] Handle percent-encoding and reject null bytes (from PR#18)
296336 let decoded = percent_decode_path ( path_only) ;
297337 if decoded. as_bytes ( ) . contains ( & 0 ) {
298- return None ;
338+ return ResolveResult :: NotFound ;
299339 }
340+
300341 let rel = decoded. trim_start_matches ( '/' ) ;
301342 let mut file_path = site_canon. to_path_buf ( ) ;
302343 if !rel. is_empty ( ) {
303344 for seg in rel. split ( '/' ) . filter ( |s| !s. is_empty ( ) ) {
345+ // [Security] Block directory traversal (from PR#18)
304346 if seg == ".." {
305- return None ;
347+ return ResolveResult :: NotFound ;
306348 }
307349 file_path. push ( seg) ;
308350 }
309351 }
352+
310353 if file_path. is_dir ( ) {
354+ // If it refers to a directory but lacks a trailing slash, redirect so relative links work.
355+ if !request_target. ends_with ( '/' ) && !request_target. is_empty ( ) {
356+ return ResolveResult :: Redirect ( format ! ( "{path_only}/" ) ) ;
357+ }
311358 file_path. push ( "index.html" ) ;
312359 }
313- let real = fs:: canonicalize ( & file_path) . ok ( ) ?;
314- if !real. starts_with ( site_canon) {
315- return None ;
360+
361+ // [Security] Canonicalize and verify we're still within site_canon (from PR#18)
362+ let real = match fs:: canonicalize ( & file_path) {
363+ Ok ( r) => r,
364+ Err ( _) => return ResolveResult :: NotFound ,
365+ } ;
366+
367+ if !real. starts_with ( site_canon) || !real. is_file ( ) {
368+ return ResolveResult :: NotFound ;
316369 }
317- real. is_file ( ) . then_some ( real)
370+
371+ ResolveResult :: File ( real)
318372}
319373
320374fn hex_val ( c : u8 ) -> Option < u8 > {
@@ -360,79 +414,54 @@ fn cmd_serve() {
360414 println ! ( "\n Serving at http://{addr} (Ctrl+C to stop)" ) ;
361415
362416 for stream in listener. incoming ( ) {
363- let Ok ( stream) = stream else { continue } ;
364- handle_request ( stream, & site_canon) ;
365- }
366- }
367-
368- fn handle_request ( mut stream : std:: net:: TcpStream , site_canon : & Path ) {
369- let mut buf = [ 0u8 ; 4096 ] ;
370- let n = stream. read ( & mut buf) . unwrap_or ( 0 ) ;
371- let request = String :: from_utf8_lossy ( & buf[ ..n] ) ;
372-
373- let path = request
374- . lines ( )
375- . next ( )
376- . and_then ( |line| line. split_whitespace ( ) . nth ( 1 ) )
377- . unwrap_or ( "/" ) ;
378-
379- if let Some ( file_path) = resolve_site_file ( site_canon, path) {
380- let body = fs:: read ( & file_path) . unwrap_or_default ( ) ;
381- let mime = guess_mime ( & file_path) ;
382- let header = format ! (
383- "HTTP/1.1 200 OK\r \n Content-Type: {mime}\r \n Content-Length: {}\r \n \r \n " ,
384- body. len( )
385- ) ;
386- let _ = stream. write_all ( header. as_bytes ( ) ) ;
387- let _ = stream. write_all ( & body) ;
388- } else {
389- let body = b"404 Not Found" ;
390- let header = format ! (
391- "HTTP/1.1 404 Not Found\r \n Content-Length: {}\r \n \r \n " ,
392- body. len( )
393- ) ;
394- let _ = stream. write_all ( header. as_bytes ( ) ) ;
395- let _ = stream. write_all ( body) ;
417+ let Ok ( mut stream) = stream else { continue } ;
418+ let mut buf = [ 0u8 ; 4096 ] ;
419+ let n = stream. read ( & mut buf) . unwrap_or ( 0 ) ;
420+ let request = String :: from_utf8_lossy ( & buf[ ..n] ) ;
421+
422+ let path = request
423+ . lines ( )
424+ . next ( )
425+ . and_then ( |line| line. split_whitespace ( ) . nth ( 1 ) )
426+ . unwrap_or ( "/" ) ;
427+
428+ match resolve_site_file ( & site_canon, path) {
429+ ResolveResult :: File ( file_path) => {
430+ let body = fs:: read ( & file_path) . unwrap_or_default ( ) ;
431+ let mime = guess_mime ( & file_path) ;
432+ let header = format ! (
433+ "HTTP/1.1 200 OK\r \n Content-Type: {mime}\r \n Content-Length: {}\r \n \r \n " ,
434+ body. len( )
435+ ) ;
436+ let _ = stream. write_all ( header. as_bytes ( ) ) ;
437+ let _ = stream. write_all ( & body) ;
438+ }
439+ ResolveResult :: Redirect ( new_path) => {
440+ let header = format ! (
441+ "HTTP/1.1 301 Moved Permanently\r \n Location: {new_path}\r \n Content-Length: 0\r \n \r \n "
442+ ) ;
443+ let _ = stream. write_all ( header. as_bytes ( ) ) ;
444+ }
445+ ResolveResult :: NotFound => {
446+ let body = b"404 Not Found" ;
447+ let header = format ! (
448+ "HTTP/1.1 404 Not Found\r \n Content-Length: {}\r \n \r \n " ,
449+ body. len( )
450+ ) ;
451+ let _ = stream. write_all ( header. as_bytes ( ) ) ;
452+ let _ = stream. write_all ( body) ;
453+ }
454+ }
396455 }
397456}
398457
399458/// Install a Ctrl+C handler that exits cleanly (code 0) instead of
400459/// letting the OS terminate with STATUS_CONTROL_C_EXIT.
401460fn ctrlc_exit ( ) {
402- unsafe {
403- libc_set_handler ( ) ;
404- }
405- }
406-
407- #[ cfg( windows) ]
408- unsafe fn libc_set_handler ( ) {
409- // SetConsoleCtrlHandler via the Windows API
410- extern "system" {
411- fn SetConsoleCtrlHandler (
412- handler : Option < unsafe extern "system" fn ( u32 ) -> i32 > ,
413- add : i32 ,
414- ) -> i32 ;
415- }
416- unsafe extern "system" fn handler ( _ctrl_type : u32 ) -> i32 {
461+ ctrlc:: set_handler ( move || {
417462 std:: process:: exit ( 0 ) ;
418- }
419- unsafe {
420- SetConsoleCtrlHandler ( Some ( handler) , 1 ) ;
421- }
422- }
423-
424- #[ cfg( not( windows) ) ]
425- unsafe fn libc_set_handler ( ) {
426- // On Unix, register SIGINT via libc
427- extern "C" {
428- fn signal ( sig : i32 , handler : extern "C" fn ( i32 ) ) -> usize ;
429- }
430- extern "C" fn handler ( _sig : i32 ) {
431- std:: process:: exit ( 0 ) ;
432- }
433- unsafe {
434- signal ( 2 /* SIGINT */ , handler) ;
435- }
463+ } )
464+ . expect ( "Error setting Ctrl-C handler" ) ;
436465}
437466
438467fn guess_mime ( path : & Path ) -> & ' static str {
@@ -461,4 +490,4 @@ fn cmd_clean() {
461490 println ! ( "Removed {dir_name}/" ) ;
462491 }
463492 }
464- }
493+ }
0 commit comments