Skip to content

Commit a0eb50c

Browse files
Merge pull request #65 from SyedaAnshrahGillani/improve/xtask-server-and-signals
improve(xtask): add mdbook check, fix directory redirects, and use safe signal handling
2 parents 024a3d6 + d82d31d commit a0eb50c

2 files changed

Lines changed: 108 additions & 76 deletions

File tree

xtask/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ name = "xtask"
33
version = "0.1.0"
44
edition = "2021"
55
publish = false
6+
7+
[dependencies]
8+
ctrlc = "3.4"

xtask/src/main.rs

Lines changed: 105 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,32 @@ Commands:
9999
// ── build ────────────────────────────────────────────────────────────
100100

101101
fn 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

105109
fn 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!("\nTo 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+
110128
fn 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

320374
fn hex_val(c: u8) -> Option<u8> {
@@ -360,79 +414,54 @@ fn cmd_serve() {
360414
println!("\nServing 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\nContent-Type: {mime}\r\nContent-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\nContent-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\nContent-Type: {mime}\r\nContent-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\nLocation: {new_path}\r\nContent-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\nContent-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.
401460
fn 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

438467
fn 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

Comments
 (0)