perf: findings 6.1, 6.2, 2.6 — async Argon2, moka cache, full streaming migration
- 6.1: PasswordHasherPort now async_trait with spawn_blocking for Argon2
- 6.2: OIDC pending maps migrated from std::sync::Mutex to moka::sync::Cache with TTL
- 2.6: All file download paths migrated to 64KB streaming (get_file_stream / read_blob_stream)
- WOPI, dedup, batch ZIP, file_retrieval_service consumers migrated
- WebDAV COPY uses zero-copy dedup (copy_file)
- Removed dead code: get_file_content, get_file_mmap, read_blob, read_blob_bytes
from traits, impls, stubs, and mocks (18 files touched)
This commit is contained in:
@@ -31,7 +31,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::{self, File};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, BufReader};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::application::ports::dedup_ports::{
|
||||
@@ -39,10 +39,10 @@ use crate::application::ports::dedup_ports::{
|
||||
};
|
||||
use crate::domain::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Chunk size for streaming hash calculation (256KB)
|
||||
const HASH_CHUNK_SIZE: usize = 256 * 1024;
|
||||
/// Block size for SHA-256 file hashing (1MB — optimal syscall/throughput ratio).
|
||||
const HASH_BLOCK_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Chunk size for streaming file reads (256 KB — 4x fewer iterations)
|
||||
/// Chunk size for streaming file reads (256 KB)
|
||||
const STREAM_CHUNK_SIZE: usize = 256 * 1024;
|
||||
|
||||
/// Content-Addressable Storage Service (PostgreSQL-backed)
|
||||
@@ -124,22 +124,32 @@ impl DedupService {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Calculate SHA-256 hash of a file (streaming).
|
||||
/// Calculate SHA-256 hash of a file.
|
||||
///
|
||||
/// Runs entirely on `spawn_blocking` with synchronous I/O so the Tokio
|
||||
/// worker threads are never blocked by CPU-bound hashing. Uses 1 MB
|
||||
/// reads for optimal syscall-to-throughput ratio (~3.8 GB/s on NVMe).
|
||||
pub async fn hash_file(path: &Path) -> std::io::Result<String> {
|
||||
let file = File::open(path).await?;
|
||||
let mut reader = BufReader::with_capacity(HASH_CHUNK_SIZE, file);
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0u8; HASH_CHUNK_SIZE];
|
||||
let path = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::io::Read;
|
||||
|
||||
loop {
|
||||
let bytes_read = reader.read(&mut buffer).await?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
let mut file = std::fs::File::open(&path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0u8; HASH_BLOCK_SIZE];
|
||||
|
||||
loop {
|
||||
let n = file.read(&mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
})
|
||||
.await
|
||||
.expect("hash_file: spawn_blocking task panicked")
|
||||
}
|
||||
|
||||
// ── Core store operations ────────────────────────────────────
|
||||
@@ -515,27 +525,8 @@ impl DedupService {
|
||||
|
||||
// ── Read operations ──────────────────────────────────────────
|
||||
|
||||
/// Read blob content from the filesystem.
|
||||
pub async fn read_blob(&self, hash: &str) -> Result<Vec<u8>, DomainError> {
|
||||
let blob_path = self.blob_path(hash);
|
||||
|
||||
fs::read(&blob_path).await.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Blob",
|
||||
format!("Failed to read blob {}: {}", hash, e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Read blob content as Bytes.
|
||||
pub async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, DomainError> {
|
||||
self.read_blob(hash).await.map(Bytes::from)
|
||||
}
|
||||
|
||||
/// Stream blob content in 64 KB chunks — constant memory (~64 KB per stream).
|
||||
///
|
||||
/// Unlike `read_blob()`, this never loads the entire file into RAM.
|
||||
/// A 1 GB file uses the same ~64 KB as a 1 KB file.
|
||||
pub async fn read_blob_stream(
|
||||
&self,
|
||||
@@ -772,14 +763,6 @@ impl DedupPort for DedupService {
|
||||
self.get_blob_metadata(hash).await
|
||||
}
|
||||
|
||||
async fn read_blob(&self, hash: &str) -> Result<Vec<u8>, DomainError> {
|
||||
self.read_blob(hash).await
|
||||
}
|
||||
|
||||
async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, DomainError> {
|
||||
self.read_blob_bytes(hash).await
|
||||
}
|
||||
|
||||
async fn read_blob_stream(
|
||||
&self,
|
||||
hash: &str,
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
//!
|
||||
//! This module provides a secure password hashing implementation using the Argon2id
|
||||
//! algorithm, which is the recommended choice for password hashing as of 2023+.
|
||||
//!
|
||||
//! Both `hash_password` and `verify_password` are CPU-intensive (~300-500 ms with
|
||||
//! default parameters) so they run inside `spawn_blocking` to avoid blocking Tokio
|
||||
//! worker threads.
|
||||
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use async_trait::async_trait;
|
||||
use rand_core::OsRng;
|
||||
|
||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||
@@ -14,9 +19,11 @@ use crate::common::errors::{DomainError, ErrorKind};
|
||||
///
|
||||
/// Uses Argon2id algorithm which provides resistance against both side-channel
|
||||
/// and GPU-based attacks. This is the recommended algorithm for password hashing.
|
||||
///
|
||||
/// The struct is stateless — `Argon2::default()` is constructed per call inside
|
||||
/// `spawn_blocking` so it is `Send` without extra synchronisation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Argon2PasswordHasher {
|
||||
/// Argon2 hasher instance - uses default secure parameters
|
||||
_private: (),
|
||||
}
|
||||
|
||||
@@ -33,35 +40,57 @@ impl Default for Argon2PasswordHasher {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PasswordHasherPort for Argon2PasswordHasher {
|
||||
fn hash_password(&self, password: &str) -> Result<String, DomainError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"PasswordHasher",
|
||||
format!("Error generating password hash: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_password(&self, password: &str, hash: &str) -> Result<bool, DomainError> {
|
||||
let parsed_hash = PasswordHash::new(hash).map_err(|e| {
|
||||
async fn hash_password(&self, password: &str) -> Result<String, DomainError> {
|
||||
let pwd = password.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(pwd.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"PasswordHasher",
|
||||
format!("Error generating password hash: {}", e),
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"PasswordHasher",
|
||||
format!("Error processing password hash: {}", e),
|
||||
format!("Task join error: {}", e),
|
||||
)
|
||||
})?;
|
||||
})?
|
||||
}
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
async fn verify_password(&self, password: &str, hash: &str) -> Result<bool, DomainError> {
|
||||
let pwd = password.to_owned();
|
||||
let hash = hash.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parsed_hash = PasswordHash::new(&hash).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"PasswordHasher",
|
||||
format!("Error processing password hash: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(pwd.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"PasswordHasher",
|
||||
format!("Task join error: {}", e),
|
||||
)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,33 +98,36 @@ impl PasswordHasherPort for Argon2PasswordHasher {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
#[tokio::test]
|
||||
async fn test_hash_and_verify_password() {
|
||||
let hasher = Argon2PasswordHasher::new();
|
||||
let password = "test_password_123";
|
||||
|
||||
let hash = hasher
|
||||
.hash_password(password)
|
||||
.await
|
||||
.expect("Should hash password");
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password(password, &hash)
|
||||
.await
|
||||
.expect("Should verify")
|
||||
);
|
||||
assert!(
|
||||
!hasher
|
||||
.verify_password("wrong_password", &hash)
|
||||
.await
|
||||
.expect("Should verify")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_password() {
|
||||
#[tokio::test]
|
||||
async fn test_different_hashes_for_same_password() {
|
||||
let hasher = Argon2PasswordHasher::new();
|
||||
let password = "same_password";
|
||||
|
||||
let hash1 = hasher.hash_password(password).expect("Should hash");
|
||||
let hash2 = hasher.hash_password(password).expect("Should hash");
|
||||
let hash1 = hasher.hash_password(password).await.expect("Should hash");
|
||||
let hash2 = hasher.hash_password(password).await.expect("Should hash");
|
||||
|
||||
// Hashes should be different due to random salt
|
||||
assert_ne!(hash1, hash2);
|
||||
@@ -104,11 +136,13 @@ mod tests {
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password(password, &hash1)
|
||||
.await
|
||||
.expect("Should verify")
|
||||
);
|
||||
assert!(
|
||||
hasher
|
||||
.verify_password(password, &hash2)
|
||||
.await
|
||||
.expect("Should verify")
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user