Files
finger/rust/src/handler.rs
T
waffle2k dcbcff98a6
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
Add Rust port of the finger daemon
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.
2026-07-23 21:24:55 -07:00

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")
);
}
}