use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use argon2::{Algorithm, Argon2, Params, Version};
use password_hash::{
rand_core::{OsRng, RngCore},
PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::errors::{Error, Result};
use crate::util::constant_time_eq;
const ARGON2_DIRECTIVE_PREFIX: &str = "# alighieri:user:argon2:";
const DUMMY_ARGON2_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$C7It2r7AayL9ud0k5lZByEYkYBm2MDc36XwDo7OZH34";
const MAX_CONCURRENT_PASSWORD_VERIFICATIONS: usize = 4;
const MAX_CONCURRENT_AUTH_COMMANDS: usize = 64;
const REAP_WAIT_TIMEOUT: Duration = Duration::from_secs(5);
const RFC1929_FIELD_MAX: usize = u8::MAX as usize;
const MAX_VERIFIED_CACHE_ENTRIES: usize = 1024;
const CACHE_TAG_M_COST_KIB: u32 = 8;
const CACHE_TAG_LEN: usize = 32;
#[derive(Debug, Clone)]
pub struct UserDb {
users: HashMap<String, StoredCredential>,
verified: Arc<VerifiedCache>,
}
impl Default for UserDb {
fn default() -> Self {
UserDb {
users: HashMap::new(),
verified: Arc::new(VerifiedCache::new()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StoredCredential {
Plain(String),
Argon2(String),
}
impl UserDb {
pub fn new() -> Self {
UserDb::default()
}
pub fn load(path: &Path) -> Result<UserDb> {
let text = std::fs::read_to_string(path).map_err(|e| {
Error::Config(format!("failed to read userlist {}: {e}", path.display()))
})?;
UserDb::parse(&text)
}
pub fn parse(text: &str) -> Result<UserDb> {
let mut users = HashMap::new();
for (i, line) in text.lines().enumerate() {
let lineno = i + 1;
let trimmed = line.trim();
if let Some((user, credential)) = parse_argon2_directive(trimmed).map_err(|e| {
Error::Config(format!("userlist line {lineno}: invalid Argon2 entry: {e}"))
})? {
if users.contains_key(&user) {
tracing::warn!(
line = lineno,
user = %user,
"duplicate userlist username; the later entry overrides the earlier one"
);
}
users.insert(user, credential);
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (user, pass) = trimmed.split_once(':').ok_or_else(|| {
Error::Config(format!(
"userlist line {lineno}: expected 'username:password'"
))
})?;
let user = user.trim();
if user.is_empty() {
return Err(Error::Config(format!(
"userlist line {lineno}: empty username"
)));
}
if users.contains_key(user) {
tracing::warn!(
line = lineno,
user = %user,
"duplicate userlist username; the later entry overrides the earlier one"
);
}
users.insert(user.to_string(), StoredCredential::Plain(pass.to_string()));
}
Ok(UserDb {
users,
..UserDb::default()
})
}
pub fn hash_user_line(username: &str, password: &str) -> Result<String> {
validate_username(username)?;
validate_password(password)?;
let hash = hash_password(password)?;
Ok(format!(
"{ARGON2_DIRECTIVE_PREFIX}{}:{hash}",
hex_encode(username.as_bytes())
))
}
pub fn entry_username(line: &str) -> Option<String> {
let trimmed = line.trim();
if let Ok(Some(user)) = parse_argon2_directive_username(trimmed) {
return Some(user);
}
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
trimmed
.split_once(':')
.map(|(user, _)| user.trim().to_string())
.filter(|user| !user.is_empty())
}
pub fn len(&self) -> usize {
self.users.len()
}
pub fn is_empty(&self) -> bool {
self.users.is_empty()
}
pub fn verify(&self, username: &str, password: &str) -> bool {
verify_stored(self.users.get(username), password)
}
pub async fn verify_async(
&self,
username: &str,
password: &str,
cache_ttl: Option<Duration>,
) -> bool {
let cached = cache_ttl.and_then(|ttl| {
let tag = self.verified.tag(username, password)?;
Some((tag, ttl))
});
if let Some((tag, _)) = &cached {
if self.verified.check(username, tag, Instant::now()) {
return true;
}
}
let stored = self.users.get(username).cloned();
let password = password.to_string();
let Ok(permit) = password_verify_semaphore().clone().acquire_owned().await else {
return false;
};
let ok = tokio::task::spawn_blocking(move || {
let _permit = permit;
verify_stored(stored.as_ref(), &password)
})
.await
.unwrap_or(false);
if ok {
if let Some((tag, ttl)) = cached {
let now = Instant::now();
if let Some(expires_at) = now.checked_add(ttl) {
self.verified.store(username, tag, expires_at, now);
}
}
}
ok
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthOutcome {
Allowed,
Denied,
TimedOut,
}
#[derive(Debug)]
pub struct CommandAuth {
program: String,
args: Vec<String>,
verified: VerifiedCache,
limiter: Arc<Semaphore>,
}
impl CommandAuth {
pub fn new(command: &[String]) -> Option<Self> {
let (program, args) = command.split_first()?;
Some(CommandAuth {
program: program.clone(),
args: args.to_vec(),
verified: VerifiedCache::new(),
limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_AUTH_COMMANDS)),
})
}
pub async fn verify_async(
&self,
username: &str,
password: &str,
cache_ttl: Option<Duration>,
timeout: Duration,
) -> AuthOutcome {
if [username, password]
.iter()
.any(|s| s.contains(['\n', '\r', '\0']))
{
return AuthOutcome::Denied;
}
let cached = cache_ttl.and_then(|ttl| Some((self.verified.tag(username, password)?, ttl)));
if let Some((tag, _)) = &cached {
if self.verified.check(username, tag, Instant::now()) {
return AuthOutcome::Allowed;
}
}
let allowed = match tokio::time::timeout(timeout, self.run(username, password)).await {
Ok(ok) => ok,
Err(_) => {
tracing::debug!(program = %self.program, "auth.command timed out");
return AuthOutcome::TimedOut;
}
};
if !allowed {
return AuthOutcome::Denied;
}
if let Some((tag, ttl)) = cached {
let now = Instant::now();
if let Some(expires_at) = now.checked_add(ttl) {
self.verified.store(username, tag, expires_at, now);
}
}
AuthOutcome::Allowed
}
async fn run(&self, username: &str, password: &str) -> bool {
let Ok(permit) = self.limiter.clone().acquire_owned().await else {
return false; };
let child = match tokio::process::Command::new(&self.program)
.args(&self.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(e) => {
tracing::warn!(error = %e, program = %self.program, "auth.command failed to spawn");
return false; }
};
let mut guard = ChildReaper::new(child, permit);
let delivered = match guard.child().stdin.take() {
Some(mut stdin) => {
use tokio::io::AsyncWriteExt;
stdin.write_all(username.as_bytes()).await.is_ok()
&& stdin.write_all(b"\n").await.is_ok()
&& stdin.write_all(password.as_bytes()).await.is_ok()
&& stdin.write_all(b"\n").await.is_ok()
}
None => false,
};
if !delivered {
return false; }
let status = guard.child().wait().await;
if status.is_ok() {
guard.disarm();
}
matches!(status, Ok(status) if status.success())
}
}
struct ChildReaper {
child: Option<tokio::process::Child>,
permit: Option<OwnedSemaphorePermit>,
}
impl ChildReaper {
fn new(child: tokio::process::Child, permit: OwnedSemaphorePermit) -> Self {
ChildReaper {
child: Some(child),
permit: Some(permit),
}
}
fn child(&mut self) -> &mut tokio::process::Child {
self.child.as_mut().expect("child present until disarmed")
}
fn disarm(&mut self) {
self.child = None;
self.permit = None;
}
}
impl Drop for ChildReaper {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let permit = self.permit.take();
let _ = child.start_kill();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let _ = tokio::time::timeout(REAP_WAIT_TIMEOUT, child.wait()).await;
drop(permit);
});
}
}
}
}
struct VerifiedCache {
salt: [u8; 16],
entries: Mutex<HashMap<String, VerifiedEntry>>,
}
struct VerifiedEntry {
tag: [u8; CACHE_TAG_LEN],
expires_at: Instant,
}
impl VerifiedCache {
fn new() -> Self {
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
VerifiedCache {
salt,
entries: Mutex::new(HashMap::new()),
}
}
fn tag(&self, username: &str, password: &str) -> Option<[u8; CACHE_TAG_LEN]> {
let params = Params::new(CACHE_TAG_M_COST_KIB, 1, 1, Some(CACHE_TAG_LEN)).ok()?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut material = Vec::with_capacity(username.len() + 1 + password.len());
material.extend_from_slice(username.as_bytes());
material.push(0);
material.extend_from_slice(password.as_bytes());
let mut tag = [0u8; CACHE_TAG_LEN];
argon
.hash_password_into(&material, &self.salt, &mut tag)
.ok()?;
Some(tag)
}
fn check(&self, username: &str, tag: &[u8; CACHE_TAG_LEN], now: Instant) -> bool {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let Some(entry) = entries.get(username) else {
return false;
};
if entry.expires_at <= now {
entries.remove(username);
return false;
}
constant_time_eq(&entry.tag, tag)
}
fn store(&self, username: &str, tag: [u8; CACHE_TAG_LEN], expires_at: Instant, now: Instant) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if entries.len() >= MAX_VERIFIED_CACHE_ENTRIES && !entries.contains_key(username) {
entries.retain(|_, entry| entry.expires_at > now);
}
if entries.len() >= MAX_VERIFIED_CACHE_ENTRIES && !entries.contains_key(username) {
let oldest = entries
.iter()
.min_by_key(|(_, entry)| entry.expires_at)
.map(|(user, _)| user.clone());
if let Some(oldest) = oldest {
entries.remove(&oldest);
}
}
entries.insert(username.to_string(), VerifiedEntry { tag, expires_at });
}
}
impl std::fmt::Debug for VerifiedCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VerifiedCache").finish_non_exhaustive()
}
}
fn password_verify_semaphore() -> &'static Arc<Semaphore> {
static SEMAPHORE: OnceLock<Arc<Semaphore>> = OnceLock::new();
SEMAPHORE.get_or_init(|| Arc::new(Semaphore::new(MAX_CONCURRENT_PASSWORD_VERIFICATIONS)))
}
fn verify_stored(stored: Option<&StoredCredential>, password: &str) -> bool {
match stored {
Some(StoredCredential::Plain(stored)) => {
let ok = constant_time_eq(stored.as_bytes(), password.as_bytes());
let _ = dummy_argon2_credential().verify(password);
ok
}
Some(stored) => stored.verify(password),
None => {
let _ = constant_time_eq(password.as_bytes(), password.as_bytes());
let _ = dummy_argon2_credential().verify(password);
false
}
}
}
impl StoredCredential {
fn parse_argon2(phc: &str) -> std::result::Result<Self, String> {
let parsed = PasswordHash::new(phc).map_err(|e| e.to_string())?;
if parsed.algorithm.as_str() != "argon2id" {
return Err("expected argon2id PHC hash".into());
}
if parsed.hash.is_none() {
return Err("missing hash output".into());
}
Ok(StoredCredential::Argon2(phc.to_string()))
}
fn verify(&self, password: &str) -> bool {
match self {
StoredCredential::Plain(stored) => {
constant_time_eq(stored.as_bytes(), password.as_bytes())
}
StoredCredential::Argon2(stored) => PasswordHash::new(stored)
.ok()
.and_then(|hash| {
Argon2::default()
.verify_password(password.as_bytes(), &hash)
.ok()
})
.is_some(),
}
}
}
fn dummy_argon2_credential() -> &'static StoredCredential {
static DUMMY: OnceLock<StoredCredential> = OnceLock::new();
DUMMY.get_or_init(|| {
StoredCredential::parse_argon2(DUMMY_ARGON2_HASH).expect("dummy Argon2 hash is valid")
})
}
fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|e| Error::Config(format!("failed to hash password: {e}")))
}
fn parse_argon2_directive(
trimmed: &str,
) -> std::result::Result<Option<(String, StoredCredential)>, String> {
let Some((username, phc)) = parse_argon2_directive_parts(trimmed)? else {
return Ok(None);
};
let credential = StoredCredential::parse_argon2(phc)?;
Ok(Some((username, credential)))
}
fn parse_argon2_directive_username(trimmed: &str) -> std::result::Result<Option<String>, String> {
Ok(parse_argon2_directive_parts(trimmed)?.map(|(username, _)| username))
}
fn parse_argon2_directive_parts(
trimmed: &str,
) -> std::result::Result<Option<(String, &str)>, String> {
let Some(rest) = trimmed.strip_prefix(ARGON2_DIRECTIVE_PREFIX) else {
return Ok(None);
};
let (encoded_user, phc) = rest
.split_once(':')
.ok_or_else(|| "expected encoded username and PHC hash".to_string())?;
let username = hex_decode_utf8(encoded_user)?;
validate_username(&username).map_err(|e| e.to_string())?;
Ok(Some((username, phc)))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
fn hex_decode_utf8(encoded: &str) -> std::result::Result<String, String> {
if !encoded.len().is_multiple_of(2) {
return Err("encoded username must have an even number of hex digits".into());
}
let mut bytes = Vec::with_capacity(encoded.len() / 2);
for pair in encoded.as_bytes().chunks_exact(2) {
let high = hex_value(pair[0])?;
let low = hex_value(pair[1])?;
bytes.push((high << 4) | low);
}
String::from_utf8(bytes).map_err(|e| e.to_string())
}
fn hex_value(byte: u8) -> std::result::Result<u8, String> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err("encoded username contains a non-hex digit".into()),
}
}
fn validate_username(username: &str) -> Result<()> {
if username.trim().is_empty() {
return Err(Error::Config("username must not be empty".into()));
}
if username != username.trim() {
return Err(Error::Config(
"username must not contain leading or trailing whitespace".into(),
));
}
if username.contains(':') || username.contains('\n') || username.contains('\r') {
return Err(Error::Config(
"username must not contain ':', CR, or LF".into(),
));
}
if username.len() > RFC1929_FIELD_MAX {
return Err(Error::Config(
"username must not exceed 255 bytes for SOCKS5 username/password authentication".into(),
));
}
Ok(())
}
fn validate_password(password: &str) -> Result<()> {
if password.len() > RFC1929_FIELD_MAX {
return Err(Error::Config(
"password must not exceed 255 bytes for SOCKS5 username/password authentication".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_userlist() {
let db = UserDb::parse("alice:s3cr3t\nbob:hunter2\n").unwrap();
assert_eq!(db.len(), 2);
assert!(db.verify("alice", "s3cr3t"));
assert!(db.verify("bob", "hunter2"));
}
#[test]
fn parse_duplicate_username_keeps_last_entry() {
let db = UserDb::parse("alice:first\nalice:second\n").unwrap();
assert_eq!(db.len(), 1);
assert!(db.verify("alice", "second"));
assert!(!db.verify("alice", "first"));
}
#[test]
fn argon2_hash_user_line_verifies() {
let line = UserDb::hash_user_line("alice", "s3cr3t").unwrap();
assert!(line.starts_with("# alighieri:user:argon2:616c696365:$argon2id$"));
let db = UserDb::parse(&line).unwrap();
assert!(db.verify("alice", "s3cr3t"));
assert!(!db.verify("alice", "wrong"));
}
#[test]
fn invalid_argon2_hash_is_rejected() {
let err = UserDb::parse("# alighieri:user:argon2:616c696365:$argon2id$not-a-valid-phc")
.unwrap_err();
assert!(err.to_string().contains("invalid Argon2 entry"));
}
#[test]
fn non_argon2id_hash_is_rejected() {
let err = UserDb::parse(
"# alighieri:user:argon2:616c696365:$argon2i$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$C7It2r7AayL9ud0k5lZByEYkYBm2MDc36XwDo7OZH34",
)
.unwrap_err();
assert!(err.to_string().contains("argon2id"));
}
#[test]
fn plaintext_argon2_prefix_without_marker_remains_plaintext() {
let db = UserDb::parse("alice:$argon2-secret").unwrap();
assert!(db.verify("alice", "$argon2-secret"));
}
#[test]
fn plaintext_argon2_marker_without_phc_remains_plaintext() {
let db = UserDb::parse("alice:argon2:not-a-phc").unwrap();
assert!(db.verify("alice", "argon2:not-a-phc"));
}
#[test]
fn plaintext_argon2_phc_looking_password_remains_plaintext() {
let password = "argon2:$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$C7It2r7AayL9ud0k5lZByEYkYBm2MDc36XwDo7OZH34";
let db = UserDb::parse(&format!("alice:{password}")).unwrap();
assert!(db.verify("alice", password));
}
#[test]
fn entry_username_handles_plain_and_argon2_entries() {
let line = UserDb::hash_user_line("alice", "s3cr3t").unwrap();
assert_eq!(UserDb::entry_username(&line).as_deref(), Some("alice"));
assert_eq!(
UserDb::entry_username("# alighieri:user:argon2:616c696365:$argon2id$not-a-valid-phc")
.as_deref(),
Some("alice")
);
assert_eq!(UserDb::entry_username("bob:pw").as_deref(), Some("bob"));
assert_eq!(
UserDb::entry_username("alice :pw").as_deref(),
Some("alice")
);
assert_eq!(UserDb::entry_username("# just a comment"), None);
}
#[test]
fn reject_wrong_password() {
let db = UserDb::parse("alice:s3cr3t").unwrap();
assert!(!db.verify("alice", "wrong"));
}
#[test]
fn reject_unknown_user() {
let db = UserDb::parse("alice:s3cr3t").unwrap();
assert!(!db.verify("eve", "whatever"));
}
#[test]
fn ignores_comments_and_blanks() {
let db = UserDb::parse("# users\n\nalice:pw\n\n# end\n").unwrap();
assert_eq!(db.len(), 1);
assert!(db.verify("alice", "pw"));
}
#[test]
fn password_with_colon() {
let db = UserDb::parse("alice:a:b:c").unwrap();
assert!(db.verify("alice", "a:b:c"));
}
#[test]
fn missing_colon_is_error() {
let err = UserDb::parse("aliceNoColon").unwrap_err();
assert!(err.to_string().contains("expected 'username:password'"));
}
#[test]
fn empty_username_is_error() {
let err = UserDb::parse(":pw").unwrap_err();
assert!(err.to_string().contains("empty username"));
}
#[test]
fn hash_user_line_rejects_bad_username() {
let err = UserDb::hash_user_line("bad:name", "pw").unwrap_err();
assert!(err.to_string().contains("must not contain"));
}
#[test]
fn hash_user_line_rejects_surrounding_whitespace() {
let err = UserDb::hash_user_line(" alice", "pw").unwrap_err();
assert!(err.to_string().contains("whitespace"));
}
#[test]
fn hash_user_line_rejects_protocol_length_overflow() {
let long_username = "a".repeat(256);
let err = UserDb::hash_user_line(&long_username, "pw").unwrap_err();
assert!(err.to_string().contains("255 bytes"));
let long_password = "p".repeat(256);
let err = UserDb::hash_user_line("alice", &long_password).unwrap_err();
assert!(err.to_string().contains("255 bytes"));
}
#[tokio::test]
async fn async_verify_matches_sync_verifier() {
let line = UserDb::hash_user_line("alice", "s3cr3t").unwrap();
let db = UserDb::parse(&line).unwrap();
assert!(db.verify_async("alice", "s3cr3t", None).await);
assert!(!db.verify_async("alice", "wrong", None).await);
assert!(!db.verify_async("eve", "s3cr3t", None).await);
}
#[tokio::test]
async fn async_verify_caches_successful_credentials() {
let line = UserDb::hash_user_line("alice", "s3cr3t").unwrap();
let db = UserDb::parse(&line).unwrap();
let ttl = Some(Duration::from_secs(60));
assert!(db.verify_async("alice", "s3cr3t", ttl).await);
let tag = db.verified.tag("alice", "s3cr3t").unwrap();
assert!(db.verified.check("alice", &tag, Instant::now()));
assert!(!db.verify_async("alice", "wrong", ttl).await);
assert!(db.verify_async("alice", "s3cr3t", ttl).await);
}
#[tokio::test]
async fn async_verify_does_not_cache_when_disabled() {
let db = UserDb::parse("alice:s3cr3t").unwrap();
assert!(db.verify_async("alice", "s3cr3t", None).await);
let tag = db.verified.tag("alice", "s3cr3t").unwrap();
assert!(!db.verified.check("alice", &tag, Instant::now()));
}
#[tokio::test]
async fn async_verify_short_circuits_on_cached_tag() {
let db = UserDb::new();
let now = Instant::now();
let tag = db.verified.tag("ghost", "pw").unwrap();
db.verified
.store("ghost", tag, now + Duration::from_secs(60), now);
assert!(
db.verify_async("ghost", "pw", Some(Duration::from_secs(60)))
.await
);
assert!(!db.verify_async("ghost", "pw", None).await);
}
#[test]
fn verified_cache_entries_expire() {
let cache = VerifiedCache::new();
let tag = cache.tag("alice", "pw").unwrap();
let now = Instant::now();
cache.store("alice", tag, now + Duration::from_secs(10), now);
assert!(cache.check("alice", &tag, now));
assert!(!cache.check("alice", &tag, now + Duration::from_secs(10)));
}
#[test]
fn verified_cache_rejects_other_credentials() {
let cache = VerifiedCache::new();
let tag = cache.tag("alice", "pw").unwrap();
let other = cache.tag("alice", "other").unwrap();
let now = Instant::now();
cache.store("alice", tag, now + Duration::from_secs(10), now);
assert!(!cache.check("alice", &other, now));
assert!(!cache.check("bob", &tag, now));
}
#[test]
fn verified_cache_is_bounded() {
let cache = VerifiedCache::new();
let now = Instant::now();
for i in 0..=MAX_VERIFIED_CACHE_ENTRIES {
let user = format!("user{i}");
let tag = cache.tag(&user, "pw").unwrap();
cache.store(&user, tag, now + Duration::from_secs(60 + i as u64), now);
}
let entries = cache.entries.lock().unwrap();
assert_eq!(entries.len(), MAX_VERIFIED_CACHE_ENTRIES);
assert!(!entries.contains_key("user0"));
}
#[test]
fn command_auth_new_rejects_empty_command() {
assert!(CommandAuth::new(&[]).is_none());
}
#[cfg(unix)]
fn sh_auth(script: &str) -> CommandAuth {
CommandAuth::new(&["/bin/sh".to_string(), "-c".to_string(), script.to_string()]).unwrap()
}
#[cfg(unix)]
#[tokio::test]
async fn command_auth_allows_only_on_exit_zero() {
let auth = sh_auth("read u; read p; [ \"$u\" = alice ] && [ \"$p\" = secret ]");
let t = Duration::from_secs(5);
assert_eq!(
auth.verify_async("alice", "secret", None, t).await,
AuthOutcome::Allowed
);
assert_eq!(
auth.verify_async("alice", "wrong", None, t).await,
AuthOutcome::Denied
);
assert_eq!(
auth.verify_async("bob", "secret", None, t).await,
AuthOutcome::Denied
);
}
#[cfg(unix)]
#[tokio::test]
async fn command_auth_rejects_embedded_delimiters() {
let auth = sh_auth("exit 0");
for (user, pass) in [
("alice", "sec\nret"), ("alice", "sec\rret"), ("al\rice", "secret"), ("al\nice", "secret"), ("alice", "sec\0ret"), ("al\0ice", "secret"), ] {
assert_eq!(
auth.verify_async(user, pass, None, Duration::from_secs(5))
.await,
AuthOutcome::Denied,
"credentials with an embedded delimiter must be denied: {user:?}/{pass:?}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn command_auth_times_out() {
let auth = sh_auth("sleep 5");
assert_eq!(
auth.verify_async("alice", "secret", None, Duration::from_millis(100))
.await,
AuthOutcome::TimedOut
);
}
#[cfg(unix)]
#[tokio::test]
async fn command_auth_caches_success() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("ran");
let auth = sh_auth(&format!(
"cat >/dev/null; [ ! -e '{m}' ] && touch '{m}'",
m = marker.display()
));
let ttl = Some(Duration::from_secs(60));
let t = Duration::from_secs(5);
assert_eq!(
auth.verify_async("alice", "secret", ttl, t).await,
AuthOutcome::Allowed,
"first run"
);
assert_eq!(
auth.verify_async("alice", "secret", ttl, t).await,
AuthOutcome::Allowed,
"second call should hit the cache"
);
}
}