pdftract/crates/pdftract-cli/tests/root-path-protection.rs
jedarden 8dff70e404 docs(pdftract-6696g): add verification note for --root path-traversal protection
The --root DIR flag was already fully implemented in the codebase.
All 25 tests pass (12 unit + 13 integration tests).

Acceptance criteria verified:
- Path traversal rejected with -32602
- Absolute paths rejected when --root is set
- HTTPS URLs bypass the check
- Symlink escapes detected via canonicalize
- Startup validation for root directory

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-05-23 02:29:26 -04:00

210 lines
6.8 KiB
Rust

//! Integration tests for --root path-traversal protection.
//!
//! These tests verify the acceptance criteria from bead pdftract-6696g:
//! - Path-traversal attempts are rejected with -32602
//! - Absolute paths are rejected when --root is set
//! - HTTPS URLs bypass the path check
//! - Without --root, paths are not validated
//! - Symlink escapes are rejected
//! - Invalid root paths cause startup errors
use pdftract_cli::mcp::root::{canonicalize_root, resolve_path};
use std::fs;
use std::path::Path;
#[test]
fn test_acceptance_criteria_path_traversal_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
// Create a file inside root
let file_path = root.join("test.txt");
fs::write(&file_path, b"test content").unwrap();
// Try to escape with ../..
let result = resolve_path("../../../etc/passwd", Some(root));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, -32602, "Should return -32602 (Invalid params) for path traversal");
assert!(err.message.contains("escapes root"));
}
#[test]
fn test_acceptance_criteria_valid_path_within_root() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
// Create a subdirectory and file
let subdir = root.join("subdir");
fs::create_dir(&subdir).unwrap();
let file_path = subdir.join("test.pdf");
fs::write(&file_path, b"%PDF-1.4\ntest").unwrap();
// Resolve relative path
let result = resolve_path("./subdir/test.pdf", Some(root));
assert!(result.is_ok());
let resolved = result.unwrap();
assert!(resolved.starts_with(root));
assert_eq!(resolved, file_path.canonicalize().unwrap());
}
#[test]
fn test_acceptance_criteria_absolute_path_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
let result = resolve_path("/etc/passwd", Some(root));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, -32602, "Should return -32602 for absolute paths");
assert!(err.message.contains("absolute paths not permitted"));
}
#[test]
fn test_acceptance_criteria_https_url_bypasses_check() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
let result = resolve_path("https://example.com/file.pdf", Some(root));
assert!(result.is_ok());
assert_eq!(result.unwrap(), std::path::PathBuf::from("https://example.com/file.pdf"));
}
#[test]
fn test_acceptance_criteria_no_root_trust_the_caller() {
// Without --root, paths should be returned as-is (trust-the-caller mode)
let result = resolve_path("../../../etc/passwd", None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), std::path::PathBuf::from("../../../etc/passwd"));
}
#[test]
fn test_acceptance_criteria_symlink_escape_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
// Create a symlink inside root that points outside
let symlink_path = root.join("escape");
#[cfg(unix)]
{
std::os::unix::fs::symlink("/etc/passwd", &symlink_path).unwrap();
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(
r"C:\Windows\System32\drivers\etc\hosts",
&symlink_path
).unwrap();
}
// Try to access the symlink
let result = resolve_path("./escape", Some(root));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, -32602, "Should return -32602 for symlink escape");
assert!(err.message.contains("escapes root"));
}
#[test]
fn test_acceptance_criteria_nonexistent_root_startup_error() {
let result = canonicalize_root(Path::new("/nonexistent/path/that/does/not/exist"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_acceptance_criteria_file_not_directory_startup_error() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("file.txt");
fs::write(&file_path, b"test").unwrap();
let result = canonicalize_root(&file_path);
assert!(result.is_err());
assert!(result.unwrap_err().contains("must be a directory"));
}
#[test]
fn test_plan_critical_test_path_traversal_with_root() {
// Plan section 6.7 line 2346 critical test:
// "Path-traversal attempt with --root /var/data: path=\"../../etc/passwd\" rejected with -32602"
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path(); // Simulating /var/data
let result = resolve_path("../../etc/passwd", Some(root));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, -32602, "Critical test: path traversal must return -32602");
assert!(err.message.contains("escapes root"));
// Verify the error data contains the expected code
let data = err.data.unwrap();
assert_eq!(
data.get("code").unwrap().as_str(),
Some("PATH_ESCAPES_ROOT")
);
}
#[test]
fn test_http_url_bypasses_check() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
let result = resolve_path("http://example.com/file.pdf", Some(root));
assert!(result.is_ok());
assert_eq!(result.unwrap(), std::path::PathBuf::from("http://example.com/file.pdf"));
}
#[test]
fn test_dotdot_at_boundary_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
// Try to escape to parent of root
let result = resolve_path("..", Some(root));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, -32602);
assert!(err.message.contains("escapes root"));
}
#[test]
fn test_nonexistent_file_within_root_returns_error() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
let result = resolve_path("nonexistent.pdf", Some(root));
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, -32602, "Non-existent file should return -32602");
assert!(err.message.contains("path resolution failed"));
// Verify the error data contains the expected code
let data = err.data.unwrap();
assert_eq!(
data.get("code").unwrap().as_str(),
Some("PATH_RESOLUTION_FAILED")
);
}
#[test]
fn test_complex_path_traversal_patterns() {
let temp_dir = tempfile::tempdir().unwrap();
let root = temp_dir.path();
// Test various traversal patterns
let traversal_patterns = [
"../..",
"../../.",
"./../..",
"foo/../../etc",
"....//./../etc",
];
for pattern in traversal_patterns {
let result = resolve_path(pattern, Some(root));
assert!(result.is_err(), "Pattern '{}' should be rejected", pattern);
let err = result.unwrap_err();
assert_eq!(err.code, -32602, "Pattern '{}' should return -32602", pattern);
}
}