Files
lanspread/crates/lanspread-peer/src/path_validation.rs
T
ddidderr 894eb5af6a test(peer): consolidate temp dir helper
Move the repeated test TempDir implementations into a single peer
test_support module. The shared helper keeps the existing automatic cleanup
behavior and uses an atomic suffix plus timestamp so parallel tests do not
collide on the same path.

This is intentionally limited to test hygiene. It does not change the
availability model, split download.rs, or touch production scan/install
behavior beyond importing the shared helper from test modules.

Test Plan:
- git diff --check
- just fmt
- just clippy
- just test

Follow-up-Plan: FOLLOW_UP_2.md
2026-05-16 09:21:43 +02:00

198 lines
6.7 KiB
Rust

use std::path::{Component, Path, PathBuf};
use eyre::WrapErr;
fn canonicalize_base_dir(base_dir: &Path) -> eyre::Result<PathBuf> {
if !base_dir.is_absolute() {
eyre::bail!("Base directory must be absolute: {}", base_dir.display());
}
let canonical = std::fs::canonicalize(base_dir).unwrap_or_else(|_| base_dir.to_path_buf());
Ok(canonical)
}
fn sanitize_relative_path(relative_path: &str) -> eyre::Result<PathBuf> {
if relative_path.is_empty() {
eyre::bail!("Relative path cannot be empty");
}
if relative_path.contains('\0') {
eyre::bail!("Path contains null byte");
}
// Normalise Windows separators so that component checks work uniformly.
let normalised = relative_path.replace('\\', "/");
if normalised.starts_with("//") {
eyre::bail!("UNC paths are not allowed: {relative_path}");
}
if normalised.contains(":/") {
eyre::bail!("Path contains drive letter: {relative_path}");
}
let path = PathBuf::from(&normalised);
if path.is_absolute() {
eyre::bail!("Path must be relative, not absolute: {relative_path}");
}
Ok(path)
}
/// Validates and sanitizes a relative path to prevent directory traversal attacks.
///
/// # Errors
/// Returns an error if the path attempts to escape the base directory or contains invalid components.
pub fn validate_relative_path(base_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
let canonical_base = canonicalize_base_dir(base_dir)?;
let relative = sanitize_relative_path(relative_path)?;
let mut resolved = canonical_base.clone();
for component in relative.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
eyre::bail!("Path is not relative: {relative_path}");
}
Component::ParentDir => {
eyre::bail!("Path attempts to escape base directory: {relative_path}");
}
Component::CurDir => {}
Component::Normal(part) => {
resolved.push(part);
if let Ok(metadata) = std::fs::symlink_metadata(&resolved)
&& metadata.file_type().is_symlink()
{
let target = std::fs::canonicalize(&resolved).wrap_err_with(|| {
format!("Failed to canonicalize symlink {}", resolved.display())
})?;
if !target.starts_with(&canonical_base) {
eyre::bail!(
"Path validation failed: {relative_path} escapes the base directory"
);
}
resolved = target;
}
}
}
}
if !resolved.starts_with(&canonical_base) {
eyre::bail!("Path validation failed: {relative_path} escapes the base directory");
}
Ok(resolved)
}
/// Validates a relative path that will be used for accessing files within a game directory.
/// This is a stricter validation that ensures the path is relative and doesn't escape.
///
/// # Errors
/// Returns an error if the path is absolute or attempts directory traversal.
pub fn validate_game_file_path(game_dir: &Path, relative_path: &str) -> eyre::Result<PathBuf> {
validate_relative_path(game_dir, relative_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TempDir;
#[test]
fn test_valid_paths() {
let temp_dir = TempDir::new("lanspread-path-validation");
let base = temp_dir.path();
// Valid relative paths
assert!(validate_game_file_path(base, "file.txt").is_ok());
assert!(validate_game_file_path(base, "subdir/file.txt").is_ok());
assert!(validate_game_file_path(base, "subdir/deep/nested/file.txt").is_ok());
assert!(validate_game_file_path(base, "file with spaces.txt").is_ok());
assert!(validate_game_file_path(base, "./dot/./path.txt").is_ok());
#[allow(clippy::unwrap_used)]
let windows_style = validate_game_file_path(base, "mix\\windows\\path.txt").unwrap();
assert_eq!(
windows_style,
base.join("mix").join("windows").join("path.txt")
);
}
#[test]
fn test_traversal_attempts() {
let temp_dir = TempDir::new("lanspread-path-validation");
let base = temp_dir.path();
// These should all fail
assert!(validate_game_file_path(base, "../outside.txt").is_err());
assert!(validate_game_file_path(base, "subdir/../../outside.txt").is_err());
assert!(validate_game_file_path(base, "../../etc/passwd").is_err());
}
#[test]
fn test_double_dot_in_filename_allowed() {
let temp_dir = TempDir::new("lanspread-path-validation");
let base = temp_dir.path();
assert!(validate_game_file_path(base, "data/file..txt").is_ok());
}
#[test]
fn test_missing_file_stays_within_base() {
let temp_dir = TempDir::new("lanspread-path-validation");
let base = temp_dir.path();
#[allow(clippy::unwrap_used)]
let resolved = validate_game_file_path(base, "new_dir/new_file.bin").unwrap();
assert_eq!(resolved, base.join("new_dir").join("new_file.bin"));
}
#[test]
fn test_relative_base_dir_rejected() {
let relative_base = std::path::Path::new("relative/base");
assert!(validate_game_file_path(relative_base, "file.bin").is_err());
}
#[test]
fn test_absolute_paths() {
let temp_dir = TempDir::new("lanspread-path-validation");
let base = temp_dir.path();
// Absolute paths should fail
assert!(validate_game_file_path(base, "/etc/passwd").is_err());
if cfg!(windows) {
assert!(validate_game_file_path(base, "C:\\Windows\\System32\\cmd.exe").is_err());
}
}
#[test]
fn test_windows_specific() {
let temp_dir = TempDir::new("lanspread-path-validation");
let base = temp_dir.path();
// Windows-specific paths that should fail
assert!(validate_game_file_path(base, "C:\\evil.txt").is_err());
assert!(validate_game_file_path(base, "D:/evil.txt").is_err());
assert!(validate_game_file_path(base, "\\\\server\\share\\file.txt").is_err());
}
#[cfg(unix)]
#[test]
fn test_symlink_escape_rejected() {
use std::os::unix::fs::symlink;
let base_dir = TempDir::new("lanspread-path-validation-base");
let outside_dir = TempDir::new("lanspread-path-validation-outside");
let base = base_dir.path();
let outside = outside_dir.path();
symlink(outside, base.join("link")).expect("Failed to create symlink");
assert!(validate_game_file_path(base, "link/escape.txt").is_err());
}
}