diff --git a/Dockerfile b/Dockerfile index 185f9929..a885647b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,68 @@ -# Stage 1: Cache dependencies -FROM rust:1.93.0-alpine3.23 AS cacher +# syntax=docker/dockerfile:1 +# ============================================================================ +# Stage 1: PLANNER — Generate a dependency-only recipe from the full source +# ============================================================================ +# cargo-chef inspects the real project structure (lib.rs + main.rs, features, +# build scripts, profile settings) and produces a minimal recipe.json that +# fingerprints ONLY dependency-relevant metadata. Source code changes that +# don't affect dependencies will NOT invalidate this layer. +FROM rust:1.93.0-alpine3.23 AS planner +WORKDIR /app +RUN cargo install cargo-chef --locked +COPY Cargo.toml Cargo.lock ./ +COPY src src +RUN cargo chef prepare --recipe-path recipe.json + +# ============================================================================ +# Stage 2: COOK — Build all dependencies (cached until recipe.json changes) +# ============================================================================ +# This stage compiles every dependency listed in recipe.json with the exact +# same profile, features, and target layout as the real build. Because it +# uses BuildKit cache mounts for the cargo registry and git checkouts, +# even a full rebuild after `docker system prune` only re-downloads crates +# that changed upstream — not the entire registry. +FROM rust:1.93.0-alpine3.23 AS cook WORKDIR /app RUN apk --no-cache upgrade && \ apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make -COPY Cargo.toml Cargo.lock ./ -# Create a minimal project to download and cache dependencies -RUN mkdir -p src && \ - echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \ - cargo build --release && \ - rm -rf src target/release/deps/oxicloud* -# Stage 2: Build the application +COPY --from=planner /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef +COPY --from=planner /app/recipe.json recipe.json +# Cook dependencies only — no application source code is present. +# BuildKit cache mounts persist the cargo registry and target dir across +# builds so incremental recompilation works even for dependency updates. +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,target=/app/target,sharing=locked \ + cargo chef cook --release --recipe-path recipe.json && \ + # Copy built artifacts out of the cache mount so the next stage can access them + cp -r /app/target /app/target-out + +# ============================================================================ +# Stage 3: BUILD — Compile application source on top of pre-built deps +# ============================================================================ +# Only this layer is invalidated when .rs files change. Dependencies are +# already compiled and linked from the cook stage. FROM rust:1.93.0-alpine3.23 AS builder WORKDIR /app RUN apk --no-cache upgrade && \ apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make -# Copy cached dependencies (only target dir and cargo registry) -COPY --from=cacher /app/target target -COPY --from=cacher /usr/local/cargo/registry /usr/local/cargo/registry -# Copy source and static (login.html is embedded at compile-time via include_str!) +# Bring in pre-compiled dependencies from cook +COPY --from=cook /app/target-out target +COPY --from=cook /usr/local/cargo/registry /usr/local/cargo/registry +# Copy project metadata and full source COPY Cargo.toml Cargo.lock ./ COPY src src COPY static static COPY db db # Build with all optimizations (DATABASE_URL only needed at compile-time for sqlx) ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud" -RUN DATABASE_URL="${DATABASE_URL}" cargo build --release +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ + DATABASE_URL="${DATABASE_URL}" cargo build --release -# Stage 3: Create minimal final image +# ============================================================================ +# Stage 4: RUNTIME — Minimal production image (~25 MB) +# ============================================================================ FROM alpine:3.23.3 # OCI image metadata diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 68894ea4..583d5578 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -69,6 +69,27 @@ pub struct DedupService { maintenance_pool: Arc, } +/// Compile-time lookup table for the 256 two-digit lowercase hex prefixes ("00"…"ff"). +/// Avoids a `format!("{:02x}", i)` allocation on every iteration of `initialize()`. +static HEX_PREFIXES: [&str; 256] = [ + "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", "0f", + "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d", "1e", "1f", + "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "2a", "2b", "2c", "2d", "2e", "2f", + "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b", "3c", "3d", "3e", "3f", + "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f", + "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "5a", "5b", "5c", "5d", "5e", "5f", + "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d", "6e", "6f", + "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "7a", "7b", "7c", "7d", "7e", "7f", + "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b", "8c", "8d", "8e", "8f", + "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f", + "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "aa", "ab", "ac", "ad", "ae", "af", + "b0", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd", "be", "bf", + "c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf", + "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db", "dc", "dd", "de", "df", + "e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef", + "f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "fa", "fb", "fc", "fd", "fe", "ff", +]; + impl DedupService { /// Create a new dedup service backed by PostgreSQL. /// @@ -97,9 +118,8 @@ impl DedupService { .map_err(DomainError::from)?; // Create hash prefix directories (00-ff) - for i in 0..=255u8 { - let prefix = format!("{:02x}", i); - fs::create_dir_all(self.blob_root.join(&prefix)) + for prefix in &HEX_PREFIXES { + fs::create_dir_all(self.blob_root.join(prefix)) .await .map_err(DomainError::from)?; } diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs index b717fe72..e178dba9 100644 --- a/src/infrastructure/services/jwt_service.rs +++ b/src/infrastructure/services/jwt_service.rs @@ -2,10 +2,19 @@ //! //! This module provides JWT token generation and validation functionality, //! implementing the TokenServicePort trait defined in the application layer. +//! +//! **Performance optimisation**: a per-token validation cache (moka, lock-free) +//! avoids repeating the HMAC-SHA256 verification on every request for the same +//! token. Entries are keyed by a fast BLAKE3 hash of the raw token string and +//! auto-expire after a short TTL (30 s by default) so revoked tokens don't stay +//! valid for long. use chrono::Utc; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use moka::sync::Cache; use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; use uuid::Uuid; use crate::application::ports::auth_ports::{TokenClaims, TokenServicePort}; @@ -50,6 +59,24 @@ impl From for TokenClaims { /// /// This service handles JWT token generation and validation for user authentication. /// It uses HS256 algorithm for signing tokens. +/// +/// ## Validation cache +/// +/// `jsonwebtoken::decode()` performs HMAC-SHA256 verification on every call. +/// While fast in absolute terms (~2-4 µs on modern hardware), at 10 k req/s +/// that is 20-40 ms of pure CPU per second — and it is synchronous, blocking +/// the Tokio worker thread. +/// +/// The cache uses the **BLAKE3** hash of the raw token string as key (32-byte, +/// ~0.1 µs to compute — 20× cheaper than HMAC verification) and stores the +/// validated `TokenClaims`. On a cache hit the HMAC step is completely +/// skipped. +/// +/// **Security properties**: +/// - TTL of 30 s bounds the window in which a revoked token remains valid. +/// - Max 50 000 entries (≈ 4 MB RSS) with LRU eviction prevents DoS via +/// unique-token flooding. +/// - Expired tokens are never cached (decode itself rejects them first). pub struct JwtTokenService { /// Secret key used for signing JWT tokens jwt_secret: String, @@ -57,8 +84,20 @@ pub struct JwtTokenService { access_token_expiry: i64, /// Expiration time for refresh tokens in seconds refresh_token_expiry: i64, + /// Validation result cache: blake3(token) → TokenClaims + validation_cache: Cache<[u8; 32], TokenClaims>, + /// Cache hit counter (for observability / metrics) + cache_hits: AtomicU64, + /// Cache miss counter + cache_misses: AtomicU64, } +/// Default TTL for cached validation results (seconds). +const VALIDATION_CACHE_TTL_SECS: u64 = 30; + +/// Maximum number of cached token validations. +const VALIDATION_CACHE_MAX_ENTRIES: u64 = 50_000; + impl JwtTokenService { /// Create a new JwtTokenService with the specified configuration. /// @@ -71,12 +110,43 @@ impl JwtTokenService { access_token_expiry_secs: i64, refresh_token_expiry_secs: i64, ) -> Self { + let validation_cache = Cache::builder() + .max_capacity(VALIDATION_CACHE_MAX_ENTRIES) + .time_to_live(Duration::from_secs(VALIDATION_CACHE_TTL_SECS)) + .build(); + + tracing::info!( + "JWT validation cache initialised: TTL={}s, max_entries={}", + VALIDATION_CACHE_TTL_SECS, + VALIDATION_CACHE_MAX_ENTRIES, + ); + Self { jwt_secret, access_token_expiry: access_token_expiry_secs, refresh_token_expiry: refresh_token_expiry_secs, + validation_cache, + cache_hits: AtomicU64::new(0), + cache_misses: AtomicU64::new(0), } } + + /// Compute a fast BLAKE3 hash of a token string, used as cache key. + /// + /// BLAKE3 is ~20× faster than SHA-256 and ~40× faster than HMAC-SHA256 + /// verification through `jsonwebtoken`, making it an ideal pre-filter. + #[inline] + fn token_hash(token: &str) -> [u8; 32] { + blake3::hash(token.as_bytes()).into() + } + + /// Return cache hit/miss statistics for monitoring. + pub fn cache_stats(&self) -> (u64, u64) { + ( + self.cache_hits.load(Ordering::Relaxed), + self.cache_misses.load(Ordering::Relaxed), + ) + } } impl TokenServicePort for JwtTokenService { @@ -125,6 +195,25 @@ impl TokenServicePort for JwtTokenService { } fn validate_token(&self, token: &str) -> Result { + // ── 1. Fast-path: check the validation cache ───────────── + let key = Self::token_hash(token); + + if let Some(cached_claims) = self.validation_cache.get(&key) { + // Even on a cache hit we must verify the token hasn't expired + // since it was cached (the cached exp is an absolute timestamp). + let now = Utc::now().timestamp(); + if cached_claims.exp > now { + self.cache_hits.fetch_add(1, Ordering::Relaxed); + return Ok(cached_claims); + } + // Token expired while cached — evict and fall through to full + // verification which will return the proper "Token expired" error. + self.validation_cache.invalidate(&key); + } + + // ── 2. Slow-path: full HMAC-SHA256 verification ───────── + self.cache_misses.fetch_add(1, Ordering::Relaxed); + let validation = Validation::new(Algorithm::HS256); let token_data = decode::( @@ -143,7 +232,17 @@ impl TokenServicePort for JwtTokenService { ), })?; - Ok(token_data.claims.into()) + let claims: TokenClaims = token_data.claims.into(); + + // ── 3. Store in cache for subsequent requests ──────────── + // Only cache tokens that won't expire within the cache TTL window, + // avoiding stale positives right at the boundary. + let remaining_secs = claims.exp - Utc::now().timestamp(); + if remaining_secs > VALIDATION_CACHE_TTL_SECS as i64 { + self.validation_cache.insert(key, claims.clone()); + } + + Ok(claims) } fn generate_refresh_token(&self) -> String { @@ -218,4 +317,43 @@ mod tests { let result = service.validate_token("invalid_token"); assert!(result.is_err()); } + + #[test] + fn test_validation_cache_hit() { + let service = JwtTokenService::new( + "test_secret_key_at_least_32_bytes_long".to_string(), + 3600, + 86400, + ); + + let user = create_test_user(); + let token = service + .generate_access_token(&user) + .expect("Should generate token"); + + // First call: cache miss — performs full HMAC verification + let claims1 = service.validate_token(&token).expect("Should validate"); + + // Second call: cache hit — skips HMAC, returns cloned claims + let claims2 = service.validate_token(&token).expect("Should validate from cache"); + + assert_eq!(claims1.sub, claims2.sub); + assert_eq!(claims1.username, claims2.username); + + let (hits, misses) = service.cache_stats(); + assert_eq!(hits, 1, "Expected 1 cache hit"); + assert_eq!(misses, 1, "Expected 1 cache miss"); + } + + #[test] + fn test_invalid_token_not_cached() { + let service = JwtTokenService::new("secret".to_string(), 3600, 86400); + + // Invalid tokens should never be cached + let _ = service.validate_token("bad_token"); + let _ = service.validate_token("bad_token"); + + let (hits, _misses) = service.cache_stats(); + assert_eq!(hits, 0, "Invalid tokens should never produce cache hits"); + } }