CI / Build and Test (gcc, g++, ubuntu-latest) (push) Failing after 31s
CI / Code Coverage (push) Skipped
Build and Publish Docker Image / build-and-test (push) Failing after 1m7s
Build and Publish Docker Image / build-and-push-image (push) Skipped
Build and Publish Docker Image / security-scan (push) Skipped
Tokio-based reimplementation in rust/, mirroring the C++ handler and ban-tracker logic (directory-traversal checks, case-insensitive plan lookup, rolling-window IP ban tracking, allowlist parsing) along with its full test suite. Includes a matching multi-stage Dockerfile.
441 lines
13 KiB
Rust
441 lines
13 KiB
Rust
//! Resolves a finger username to a response: the contents of that user's
|
|
//! plan file if one exists and is readable, or the username echoed back
|
|
//! unchanged otherwise. Input is validated first so a request can never walk
|
|
//! outside the configured plan-file directory.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub trait FilesystemWrapper {
|
|
fn exists(&self, path: &Path) -> bool;
|
|
fn read_file(&self, path: &Path) -> String;
|
|
}
|
|
|
|
pub struct RealFilesystemWrapper;
|
|
|
|
impl FilesystemWrapper for RealFilesystemWrapper {
|
|
fn exists(&self, path: &Path) -> bool {
|
|
path.exists()
|
|
}
|
|
|
|
fn read_file(&self, path: &Path) -> String {
|
|
let raw = match std::fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(_) => return String::new(),
|
|
};
|
|
if raw.is_empty() {
|
|
return String::new();
|
|
}
|
|
// Plan files are returned over the finger protocol, which expects
|
|
// CRLF line endings; normalise whatever the file used to LF-joined
|
|
// lines terminated by a single CRLF.
|
|
let body = raw.lines().collect::<Vec<_>>().join("\n");
|
|
format!("{body}\r\n")
|
|
}
|
|
}
|
|
|
|
pub const BASE_PATH: &str = "/var/finger/users/";
|
|
|
|
pub fn process(username: &str) -> String {
|
|
let fs = RealFilesystemWrapper;
|
|
process_with(username, &fs, Path::new(BASE_PATH))
|
|
}
|
|
|
|
pub fn process_with(username: &str, fs: &dyn FilesystemWrapper, basepath: &Path) -> String {
|
|
const TRAVERSAL_PATTERNS: [&str; 10] = [
|
|
"../",
|
|
"..\\",
|
|
"%2e%2e%2f",
|
|
"%2e%2e%5c",
|
|
"%2E%2E%2F",
|
|
"%2E%2E%5C",
|
|
"..%2f",
|
|
"..%5c",
|
|
"..%2F",
|
|
"..%5C",
|
|
];
|
|
if TRAVERSAL_PATTERNS.iter().any(|p| username.contains(p)) {
|
|
return "InvalidInput: Directory traversal detected in username\r\n".to_string();
|
|
}
|
|
if username.contains('/') {
|
|
return "InvalidInput: Path detected in username\r\n".to_string();
|
|
}
|
|
|
|
// Plan-file lookup is case-insensitive: normalise the requested name to
|
|
// lower-case so e.g. "Pete" resolves the on-disk "pete" plan. Plan
|
|
// filenames are always lower-case; the original spelling is still echoed
|
|
// back below when no plan exists. Lower-casing is ASCII-only to match
|
|
// the byte-wise ::tolower behavior of the reference implementation.
|
|
let lookup = username.to_ascii_lowercase();
|
|
let plan_path: PathBuf = basepath.join(lookup);
|
|
|
|
if !fs.exists(&plan_path) {
|
|
return username.to_string();
|
|
}
|
|
|
|
let content = fs.read_file(&plan_path);
|
|
if content.is_empty() {
|
|
return username.to_string();
|
|
}
|
|
|
|
content
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
#[test]
|
|
fn valid_username_simple() {
|
|
assert_eq!(process("john"), "john");
|
|
}
|
|
|
|
#[test]
|
|
fn valid_username_with_numbers() {
|
|
assert_eq!(process("user123"), "user123");
|
|
}
|
|
|
|
#[test]
|
|
fn valid_username_with_underscore() {
|
|
assert_eq!(process("user_name"), "user_name");
|
|
}
|
|
|
|
#[test]
|
|
fn valid_username_with_hyphen() {
|
|
assert_eq!(process("user-name"), "user-name");
|
|
}
|
|
|
|
#[test]
|
|
fn valid_username_empty_string() {
|
|
assert_eq!(process(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_basic_dot_dot_slash() {
|
|
let result = process("user../file");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_basic_dot_dot_backslash() {
|
|
let result = process("user..\\file");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_url_encoded_lowercase_2e2e2f() {
|
|
let result = process("user%2e%2e%2ffile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_url_encoded_lowercase_2e2e5c() {
|
|
let result = process("user%2e%2e%5cfile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_url_encoded_uppercase_2e2e2f() {
|
|
let result = process("user%2E%2E%2Ffile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_url_encoded_uppercase_2e2e5c() {
|
|
let result = process("user%2E%2E%5Cfile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_mixed_dot_dot_2f() {
|
|
let result = process("user..%2ffile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_mixed_dot_dot_5c() {
|
|
let result = process("user..%5cfile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_mixed_dot_dot_2f_upper() {
|
|
let result = process("user..%2Ffile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn directory_traversal_mixed_dot_dot_5c_upper() {
|
|
let result = process("user..%5Cfile");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn path_detection_forward_slash() {
|
|
let result = process("user/name");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Path detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn path_detection_forward_slash_at_start() {
|
|
let result = process("/username");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Path detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn path_detection_forward_slash_at_end() {
|
|
let result = process("username/");
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Path detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn single_dot() {
|
|
assert_eq!(process("."), ".");
|
|
}
|
|
|
|
#[test]
|
|
fn double_dot_without_slash() {
|
|
assert_eq!(process(".."), "..");
|
|
}
|
|
|
|
#[test]
|
|
fn contains_dot_but_not_traversal() {
|
|
assert_eq!(process("user.name"), "user.name");
|
|
}
|
|
|
|
#[test]
|
|
fn backslash_without_dots() {
|
|
assert_eq!(process("user\\name"), "user\\name");
|
|
}
|
|
|
|
// --- Fake-filesystem tests (mirrors test_handler_mock.cpp) ---
|
|
|
|
#[derive(Default)]
|
|
struct FakeFilesystem {
|
|
files: HashMap<PathBuf, String>,
|
|
}
|
|
|
|
impl FakeFilesystem {
|
|
fn with_file(path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
|
|
let mut files = HashMap::new();
|
|
files.insert(path.into(), content.into());
|
|
FakeFilesystem { files }
|
|
}
|
|
}
|
|
|
|
impl FilesystemWrapper for FakeFilesystem {
|
|
fn exists(&self, path: &Path) -> bool {
|
|
self.files.contains_key(path)
|
|
}
|
|
|
|
fn read_file(&self, path: &Path) -> String {
|
|
self.files.get(path).cloned().unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_file_exists() {
|
|
let base = Path::new("/var/finger/users/");
|
|
let fs = FakeFilesystem::with_file(base.join("testuser"), "Mock file content\r\n");
|
|
assert_eq!(
|
|
process_with("testuser", &fs, base),
|
|
"Mock file content\r\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_file_not_found() {
|
|
let base = Path::new("/var/finger/users/");
|
|
let fs = FakeFilesystem::default();
|
|
assert_eq!(process_with("nonexistentuser", &fs, base), "nonexistentuser");
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_empty_file() {
|
|
let base = Path::new("/var/finger/users/");
|
|
let fs = FakeFilesystem::with_file(base.join("emptyfileuser"), "");
|
|
assert_eq!(process_with("emptyfileuser", &fs, base), "emptyfileuser");
|
|
}
|
|
|
|
#[test]
|
|
fn process_lowercases_username_for_lookup() {
|
|
let base = Path::new("/var/finger/users/");
|
|
let fs = FakeFilesystem::with_file(base.join("pete"), "Just another hacker.\r\n");
|
|
assert_eq!(
|
|
process_with("Pete", &fs, base),
|
|
"Just another hacker.\r\n"
|
|
);
|
|
}
|
|
|
|
// --- Real-filesystem tests (mirrors test_handler_real_filesystem.cpp) ---
|
|
|
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"finger_rs_test_{}_{n}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
TempDir(dir)
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn write_file(dir: &Path, name: &str, content: &str) {
|
|
std::fs::write(dir.join(name), content).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn real_fs_exists_with_real_file() {
|
|
let dir = TempDir::new();
|
|
write_file(dir.path(), "testuser", "Test content");
|
|
let fs = RealFilesystemWrapper;
|
|
assert!(fs.exists(&dir.path().join("testuser")));
|
|
}
|
|
|
|
#[test]
|
|
fn real_fs_exists_with_nonexistent_file() {
|
|
let dir = TempDir::new();
|
|
let fs = RealFilesystemWrapper;
|
|
assert!(!fs.exists(&dir.path().join("nonexistent")));
|
|
}
|
|
|
|
#[test]
|
|
fn real_fs_read_file_with_simple_content() {
|
|
let dir = TempDir::new();
|
|
write_file(dir.path(), "simple", "Hello, World!");
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(
|
|
fs.read_file(&dir.path().join("simple")),
|
|
"Hello, World!\r\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn real_fs_read_file_with_multiline_content() {
|
|
let dir = TempDir::new();
|
|
write_file(dir.path(), "multiline", "Line 1\nLine 2\nLine 3");
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(
|
|
fs.read_file(&dir.path().join("multiline")),
|
|
"Line 1\nLine 2\nLine 3\r\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn real_fs_read_file_with_empty_file() {
|
|
let dir = TempDir::new();
|
|
write_file(dir.path(), "empty", "");
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(fs.read_file(&dir.path().join("empty")), "");
|
|
}
|
|
|
|
#[test]
|
|
fn real_fs_read_file_nonexistent_file() {
|
|
let dir = TempDir::new();
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(fs.read_file(&dir.path().join("nonexistent")), "");
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_existing_user_file() {
|
|
let dir = TempDir::new();
|
|
write_file(
|
|
dir.path(),
|
|
"johndoe",
|
|
"John Doe\nSoftware Engineer\nLoves Rust",
|
|
);
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(
|
|
process_with("johndoe", &fs, dir.path()),
|
|
"John Doe\nSoftware Engineer\nLoves Rust\r\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_nonexistent_user_file() {
|
|
let dir = TempDir::new();
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(process_with("nonexistentuser", &fs, dir.path()), "nonexistentuser");
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_empty_user_file() {
|
|
let dir = TempDir::new();
|
|
write_file(dir.path(), "emptyuser", "");
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(process_with("emptyuser", &fs, dir.path()), "emptyuser");
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_file_containing_only_newlines() {
|
|
let dir = TempDir::new();
|
|
write_file(dir.path(), "newlineuser", "\n\n\n");
|
|
let fs = RealFilesystemWrapper;
|
|
// Line-by-line reading means the trailing empty line after the final
|
|
// \n is not read as a separate line, resulting in "\n\n\r\n".
|
|
assert_eq!(process_with("newlineuser", &fs, dir.path()), "\n\n\r\n");
|
|
}
|
|
|
|
#[test]
|
|
fn process_directory_traversal_protection_with_real_fs() {
|
|
let dir = TempDir::new();
|
|
let result = process_with("../secret", &RealFilesystemWrapper, dir.path());
|
|
assert!(result.starts_with("InvalidInput:"));
|
|
assert!(result.contains("Directory traversal detected"));
|
|
}
|
|
|
|
#[test]
|
|
fn process_with_custom_base_path() {
|
|
let dir = TempDir::new();
|
|
let custom_base = dir.path().join("custom_users");
|
|
std::fs::create_dir_all(&custom_base).unwrap();
|
|
write_file(&custom_base, "customuser", "Custom base path user");
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(
|
|
process_with("customuser", &fs, &custom_base),
|
|
"Custom base path user\r\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn real_fs_read_file_with_special_characters() {
|
|
let dir = TempDir::new();
|
|
let content = "User with special chars: \u{e0}\u{e1}\u{e2}\u{e3}\u{e4}\u{e5}\u{e6}\u{e7}\u{e8}\u{e9}\u{ea}\u{eb}";
|
|
write_file(dir.path(), "specialuser", content);
|
|
let fs = RealFilesystemWrapper;
|
|
assert_eq!(
|
|
fs.read_file(&dir.path().join("specialuser")),
|
|
format!("{content}\r\n")
|
|
);
|
|
}
|
|
}
|