| 7 |
7 |
|
|
| 8 |
8 |
|
use axum::{
|
| 9 |
9 |
|
extract::{FromRequestParts, Request},
|
|
10 |
+ |
handler::Handler,
|
| 10 |
11 |
|
http::{header::HeaderMap, request::Parts, StatusCode},
|
| 11 |
|
- |
middleware::Next,
|
|
12 |
+ |
middleware::{from_fn, Next},
|
| 12 |
13 |
|
response::{IntoResponse, Response},
|
|
14 |
+ |
routing::{delete, patch, post, put, MethodRouter},
|
|
15 |
+ |
Router,
|
| 13 |
16 |
|
};
|
| 14 |
17 |
|
use rand::RngCore;
|
| 15 |
18 |
|
use tower_sessions::Session;
|
| 129 |
132 |
|
}
|
| 130 |
133 |
|
}
|
| 131 |
134 |
|
|
| 132 |
|
- |
/// Middleware to validate CSRF tokens on state-changing requests
|
| 133 |
|
- |
///
|
| 134 |
|
- |
/// Validates POST, PUT, PATCH, DELETE requests (except for excluded paths).
|
| 135 |
|
- |
/// Checks the `X-CSRF-Token` header first (used by HTMX), then falls back to
|
| 136 |
|
- |
/// parsing the `_csrf` field from form-encoded request bodies (used by vanilla
|
| 137 |
|
- |
/// HTML forms).
|
| 138 |
|
- |
pub async fn csrf_middleware(request: Request, next: Next) -> Response {
|
| 139 |
|
- |
let method = request.method().clone();
|
|
135 |
+ |
/// Per-route CSRF posture, declared at the route registration site via the
|
|
136 |
+ |
/// `{post,put,patch,delete}_csrf*` helpers. Carried in the helper signatures
|
|
137 |
+ |
/// so the choice (and its reason) lives next to the route, not in a sibling
|
|
138 |
+ |
/// allowlist file. Not stored at runtime — the reason strings exist for
|
|
139 |
+ |
/// source-level documentation and grep, while the structural guarantee comes
|
|
140 |
+ |
/// from `CsrfRouter` only accepting `PostureMethodRouter` values.
|
|
141 |
+ |
#[derive(Clone, Copy, Debug)]
|
|
142 |
+ |
pub enum CsrfPosture {
|
|
143 |
+ |
/// Standard validation layer runs (header or form `_csrf`).
|
|
144 |
+ |
Auto,
|
|
145 |
+ |
/// Handler validates the token itself and proves it with the
|
|
146 |
+ |
/// `CsrfManuallyValidated` witness. Reason documents why the
|
|
147 |
+ |
/// standard layer can't apply (e.g. "multipart upload").
|
|
148 |
+ |
Manual(&'static str),
|
|
149 |
+ |
/// No CSRF check applies. Reason documents why (webhook signature,
|
|
150 |
+ |
/// signed link, pre-auth, etc.).
|
|
151 |
+ |
Skip(&'static str),
|
|
152 |
+ |
}
|
| 140 |
153 |
|
|
| 141 |
|
- |
// Only validate state-changing methods
|
| 142 |
|
- |
if !["POST", "PUT", "PATCH", "DELETE"].contains(&method.as_str()) {
|
| 143 |
|
- |
return next.run(request).await;
|
|
154 |
+ |
/// Witness type proving a handler ran the standard CSRF validation path.
|
|
155 |
+ |
/// The only public way to obtain one is `validate_token_consuming`, which
|
|
156 |
+ |
/// performs the check. The private field with a private-module constructor
|
|
157 |
+ |
/// makes the value un-fabricable from outside this module — `Default`,
|
|
158 |
+ |
/// struct-literal, and `Clone` are all impossible for callers.
|
|
159 |
+ |
pub use sealed::CsrfManuallyValidated;
|
|
160 |
+ |
|
|
161 |
+ |
mod sealed {
|
|
162 |
+ |
pub struct CsrfManuallyValidated {
|
|
163 |
+ |
_private: (),
|
| 144 |
164 |
|
}
|
| 145 |
165 |
|
|
| 146 |
|
- |
let path = request.uri().path().to_string();
|
|
166 |
+ |
pub(super) fn make_validated() -> CsrfManuallyValidated {
|
|
167 |
+ |
CsrfManuallyValidated { _private: () }
|
|
168 |
+ |
}
|
|
169 |
+ |
}
|
| 147 |
170 |
|
|
| 148 |
|
- |
// Exempt paths:
|
| 149 |
|
- |
// - Webhooks use their own signature verification
|
| 150 |
|
- |
// - Auth endpoints establish sessions (pre-auth, no CSRF needed)
|
| 151 |
|
- |
// - Stripe checkout is a vanilla form POST that redirects to Stripe's hosted page;
|
| 152 |
|
- |
// SameSite=Lax cookies prevent cross-site form submissions, AuthUser is required,
|
| 153 |
|
- |
// and no state mutation occurs until Stripe's webhook confirms payment
|
| 154 |
|
- |
// - /confirm-delete uses a signed HMAC link as its authorization; the user
|
| 155 |
|
- |
// arrives from an email and may not have an active session, so the
|
| 156 |
|
- |
// standard CSRF header cannot be attached to the vanilla form POST.
|
| 157 |
|
- |
// Exempt path prefixes: a path matches if it equals the prefix exactly
|
| 158 |
|
- |
// or continues with '/'. This prevents "/loginX" from matching "/login".
|
| 159 |
|
- |
let exempt_prefixes = [
|
| 160 |
|
- |
"/stripe/webhook", "/stripe/checkout", "/stripe/subscribe",
|
| 161 |
|
- |
"/login", "/join",
|
| 162 |
|
- |
"/api/sync/auth", "/api/sync/push", "/api/sync/pull", "/api/sync/status",
|
| 163 |
|
- |
"/api/sync/devices", "/api/sync/keys", "/api/sync/blobs",
|
| 164 |
|
- |
"/oauth", "/auth/passkey", "/postmark",
|
| 165 |
|
- |
"/unsubscribe", "/confirm-delete",
|
| 166 |
|
- |
"/api/checkout/guest", "/api/checkout/guest-free",
|
| 167 |
|
- |
];
|
|
171 |
+ |
/// Validate a token and return a sealed witness on success. Used by
|
|
172 |
+ |
/// handlers registered with `post_csrf_manual` (and method variants)
|
|
173 |
+ |
/// that need to validate inside the handler body — typically because the
|
|
174 |
+ |
/// global middleware can't read the token for this content type (e.g.
|
|
175 |
+ |
/// multipart) or because validation is conditional on request state.
|
|
176 |
+ |
pub async fn validate_token_consuming(
|
|
177 |
+ |
session: &Session,
|
|
178 |
+ |
provided_token: &str,
|
|
179 |
+ |
) -> Result<CsrfManuallyValidated, AppError> {
|
|
180 |
+ |
if validate_token(session, provided_token).await? {
|
|
181 |
+ |
Ok(sealed::make_validated())
|
|
182 |
+ |
} else {
|
|
183 |
+ |
Err(AppError::Forbidden)
|
|
184 |
+ |
}
|
|
185 |
+ |
}
|
| 168 |
186 |
|
|
| 169 |
|
- |
let is_exempt = exempt_prefixes.iter().any(|p| {
|
| 170 |
|
- |
path == *p || path.starts_with(&format!("{p}/"))
|
| 171 |
|
- |
});
|
| 172 |
|
- |
if is_exempt {
|
| 173 |
|
- |
return next.run(request).await;
|
|
187 |
+ |
/// Wrap a method-router with the Auto-posture validation layer.
|
|
188 |
+ |
/// Runs `validate_auto` on every request that reaches the route.
|
|
189 |
+ |
fn attach_auto_layer<S>(method_router: MethodRouter<S>) -> MethodRouter<S>
|
|
190 |
+ |
where
|
|
191 |
+ |
S: Clone + Send + Sync + 'static,
|
|
192 |
+ |
{
|
|
193 |
+ |
method_router.layer(from_fn(|req: Request, next: Next| async move {
|
|
194 |
+ |
let path = req.uri().path().to_string();
|
|
195 |
+ |
validate_auto(req, next, &path).await
|
|
196 |
+ |
}))
|
|
197 |
+ |
}
|
|
198 |
+ |
|
|
199 |
+ |
/// A `MethodRouter` that has been through one of the CSRF helpers. Field
|
|
200 |
+ |
/// is private and constructible only inside this module, so
|
|
201 |
+ |
/// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)`
|
|
202 |
+ |
/// — route files have to use the helpers, by construction.
|
|
203 |
+ |
pub use posture_router::PostureMethodRouter;
|
|
204 |
+ |
|
|
205 |
+ |
mod posture_router {
|
|
206 |
+ |
use super::*;
|
|
207 |
+ |
|
|
208 |
+ |
pub struct PostureMethodRouter<S = ()>(pub(super) MethodRouter<S>);
|
|
209 |
+ |
|
|
210 |
+ |
impl<S> PostureMethodRouter<S>
|
|
211 |
+ |
where
|
|
212 |
+ |
S: Clone + Send + Sync + 'static,
|
|
213 |
+ |
{
|
|
214 |
+ |
pub(super) fn new(inner: MethodRouter<S>) -> Self {
|
|
215 |
+ |
Self(inner)
|
|
216 |
+ |
}
|
|
217 |
+ |
|
|
218 |
+ |
pub(super) fn into_inner(self) -> MethodRouter<S> {
|
|
219 |
+ |
self.0
|
|
220 |
+ |
}
|
|
221 |
+ |
|
|
222 |
+ |
/// Attach an additional tower layer (e.g. a rate limiter) to the
|
|
223 |
+ |
/// underlying method router. Returns `Self` so callers don't lose
|
|
224 |
+ |
/// the posture stamp.
|
|
225 |
+ |
pub fn layer<L>(self, layer: L) -> Self
|
|
226 |
+ |
where
|
|
227 |
+ |
L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
|
|
228 |
+ |
L::Service:
|
|
229 |
+ |
tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
|
|
230 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Response:
|
|
231 |
+ |
axum::response::IntoResponse + 'static,
|
|
232 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Error:
|
|
233 |
+ |
Into<std::convert::Infallible> + 'static,
|
|
234 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Future:
|
|
235 |
+ |
Send + 'static,
|
|
236 |
+ |
{
|
|
237 |
+ |
Self(self.0.layer(layer))
|
|
238 |
+ |
}
|
|
239 |
+ |
}
|
|
240 |
+ |
}
|
|
241 |
+ |
|
|
242 |
+ |
macro_rules! csrf_auto_helper {
|
|
243 |
+ |
($name:ident, $axum_fn:ident) => {
|
|
244 |
+ |
pub fn $name<H, T, S>(handler: H) -> PostureMethodRouter<S>
|
|
245 |
+ |
where
|
|
246 |
+ |
H: Handler<T, S>,
|
|
247 |
+ |
T: 'static,
|
|
248 |
+ |
S: Clone + Send + Sync + 'static,
|
|
249 |
+ |
{
|
|
250 |
+ |
posture_router::PostureMethodRouter::new(attach_auto_layer($axum_fn(handler)))
|
|
251 |
+ |
}
|
|
252 |
+ |
};
|
|
253 |
+ |
}
|
|
254 |
+ |
|
|
255 |
+ |
macro_rules! csrf_passthrough_helper {
|
|
256 |
+ |
($name:ident, $axum_fn:ident, $variant:ident) => {
|
|
257 |
+ |
pub fn $name<H, T, S>(reason: &'static str, handler: H) -> PostureMethodRouter<S>
|
|
258 |
+ |
where
|
|
259 |
+ |
H: Handler<T, S>,
|
|
260 |
+ |
T: 'static,
|
|
261 |
+ |
S: Clone + Send + Sync + 'static,
|
|
262 |
+ |
{
|
|
263 |
+ |
let _ = CsrfPosture::$variant(reason);
|
|
264 |
+ |
posture_router::PostureMethodRouter::new($axum_fn(handler))
|
|
265 |
+ |
}
|
|
266 |
+ |
};
|
|
267 |
+ |
}
|
|
268 |
+ |
|
|
269 |
+ |
// Auto posture: standard CSRF validation (header or form `_csrf`).
|
|
270 |
+ |
csrf_auto_helper!(post_csrf, post);
|
|
271 |
+ |
csrf_auto_helper!(put_csrf, put);
|
|
272 |
+ |
csrf_auto_helper!(patch_csrf, patch);
|
|
273 |
+ |
csrf_auto_helper!(delete_csrf, delete);
|
|
274 |
+ |
|
|
275 |
+ |
// Manual posture: handler validates via `validate_token_consuming`.
|
|
276 |
+ |
csrf_passthrough_helper!(post_csrf_manual, post, Manual);
|
|
277 |
+ |
csrf_passthrough_helper!(put_csrf_manual, put, Manual);
|
|
278 |
+ |
csrf_passthrough_helper!(patch_csrf_manual, patch, Manual);
|
|
279 |
+ |
csrf_passthrough_helper!(delete_csrf_manual, delete, Manual);
|
|
280 |
+ |
|
|
281 |
+ |
// Skip posture: no CSRF check. Reason documents why.
|
|
282 |
+ |
csrf_passthrough_helper!(post_csrf_skip, post, Skip);
|
|
283 |
+ |
csrf_passthrough_helper!(put_csrf_skip, put, Skip);
|
|
284 |
+ |
csrf_passthrough_helper!(patch_csrf_skip, patch, Skip);
|
|
285 |
+ |
csrf_passthrough_helper!(delete_csrf_skip, delete, Skip);
|
|
286 |
+ |
|
|
287 |
+ |
// --- Wrappers for multi-method routes ------------------------------------
|
|
288 |
+ |
//
|
|
289 |
+ |
// A handful of routes register multiple HTTP methods on one path
|
|
290 |
+ |
// (e.g. `get(list).post(create)`). The handler-taking helpers above can't
|
|
291 |
+ |
// compose with these because the chain is already a `MethodRouter`. These
|
|
292 |
+ |
// wrappers take a pre-built `MethodRouter` and stamp it as a
|
|
293 |
+ |
// `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected — the
|
|
294 |
+ |
// Auto validation layer only intercepts state-changing methods at the
|
|
295 |
+ |
// per-route level because that's what the helper attached to.
|
|
296 |
+ |
|
|
297 |
+ |
/// Wrap a multi-method chain with the Auto-posture validation layer.
|
|
298 |
+ |
pub fn with_csrf<S>(method_router: MethodRouter<S>) -> PostureMethodRouter<S>
|
|
299 |
+ |
where
|
|
300 |
+ |
S: Clone + Send + Sync + 'static,
|
|
301 |
+ |
{
|
|
302 |
+ |
posture_router::PostureMethodRouter::new(attach_auto_layer(method_router))
|
|
303 |
+ |
}
|
|
304 |
+ |
|
|
305 |
+ |
/// Stamp a multi-method chain as Manual — handler is responsible for
|
|
306 |
+ |
/// calling `validate_token_consuming`.
|
|
307 |
+ |
pub fn with_csrf_manual<S>(
|
|
308 |
+ |
reason: &'static str,
|
|
309 |
+ |
method_router: MethodRouter<S>,
|
|
310 |
+ |
) -> PostureMethodRouter<S>
|
|
311 |
+ |
where
|
|
312 |
+ |
S: Clone + Send + Sync + 'static,
|
|
313 |
+ |
{
|
|
314 |
+ |
let _ = CsrfPosture::Manual(reason);
|
|
315 |
+ |
posture_router::PostureMethodRouter::new(method_router)
|
|
316 |
+ |
}
|
|
317 |
+ |
|
|
318 |
+ |
/// Stamp a multi-method chain as Skip — no CSRF check applies.
|
|
319 |
+ |
pub fn with_csrf_skip<S>(
|
|
320 |
+ |
reason: &'static str,
|
|
321 |
+ |
method_router: MethodRouter<S>,
|
|
322 |
+ |
) -> PostureMethodRouter<S>
|
|
323 |
+ |
where
|
|
324 |
+ |
S: Clone + Send + Sync + 'static,
|
|
325 |
+ |
{
|
|
326 |
+ |
let _ = CsrfPosture::Skip(reason);
|
|
327 |
+ |
posture_router::PostureMethodRouter::new(method_router)
|
|
328 |
+ |
}
|
|
329 |
+ |
|
|
330 |
+ |
// --- CsrfRouter: structural enforcement ----------------------------------
|
|
331 |
+ |
//
|
|
332 |
+ |
// `CsrfRouter` is the only way to register a mutation route in this
|
|
333 |
+ |
// codebase. Its `route` method takes a `PostureMethodRouter<S>`, whose
|
|
334 |
+ |
// constructor is private to this module, so the only producers are the
|
|
335 |
+ |
// helpers above. A bare `Router::route(path, post(handler))` cannot
|
|
336 |
+ |
// reach a mounted `CsrfRouter` without going through `finalize()` first,
|
|
337 |
+ |
// which is only called once in `build_app`.
|
|
338 |
+ |
|
|
339 |
+ |
pub struct CsrfRouter<S = ()>(Router<S>);
|
|
340 |
+ |
|
|
341 |
+ |
impl<S> Default for CsrfRouter<S>
|
|
342 |
+ |
where
|
|
343 |
+ |
S: Clone + Send + Sync + 'static,
|
|
344 |
+ |
{
|
|
345 |
+ |
fn default() -> Self {
|
|
346 |
+ |
Self::new()
|
|
347 |
+ |
}
|
|
348 |
+ |
}
|
|
349 |
+ |
|
|
350 |
+ |
impl<S> CsrfRouter<S>
|
|
351 |
+ |
where
|
|
352 |
+ |
S: Clone + Send + Sync + 'static,
|
|
353 |
+ |
{
|
|
354 |
+ |
pub fn new() -> Self {
|
|
355 |
+ |
Self(Router::new())
|
| 174 |
356 |
|
}
|
| 175 |
357 |
|
|
|
358 |
+ |
pub fn route(self, path: &str, posture: PostureMethodRouter<S>) -> Self {
|
|
359 |
+ |
Self(self.0.route(path, posture.into_inner()))
|
|
360 |
+ |
}
|
|
361 |
+ |
|
|
362 |
+ |
/// Register a read-only route (GET / HEAD / OPTIONS). The structural
|
|
363 |
+ |
/// guarantee only constrains state-changing methods, so read-only
|
|
364 |
+ |
/// `MethodRouter`s pass through unchanged. Calling this with a
|
|
365 |
+ |
/// `MethodRouter` that includes POST/PUT/PATCH/DELETE compiles, but
|
|
366 |
+ |
/// readers can see the intent at the call site — and any mutation
|
|
367 |
+ |
/// route registered through `route_get` is a bug visible in review.
|
|
368 |
+ |
pub fn route_get(self, path: &str, method_router: MethodRouter<S>) -> Self {
|
|
369 |
+ |
Self(self.0.route(path, method_router))
|
|
370 |
+ |
}
|
|
371 |
+ |
|
|
372 |
+ |
pub fn merge(self, other: Self) -> Self {
|
|
373 |
+ |
Self(self.0.merge(other.0))
|
|
374 |
+ |
}
|
|
375 |
+ |
|
|
376 |
+ |
pub fn nest(self, path: &str, other: Self) -> Self {
|
|
377 |
+ |
Self(self.0.nest(path, other.0))
|
|
378 |
+ |
}
|
|
379 |
+ |
|
|
380 |
+ |
pub fn layer<L>(self, layer: L) -> Self
|
|
381 |
+ |
where
|
|
382 |
+ |
L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
|
|
383 |
+ |
L::Service:
|
|
384 |
+ |
tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
|
|
385 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Response:
|
|
386 |
+ |
IntoResponse + 'static,
|
|
387 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Error:
|
|
388 |
+ |
Into<std::convert::Infallible> + 'static,
|
|
389 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
|
|
390 |
+ |
{
|
|
391 |
+ |
Self(self.0.layer(layer))
|
|
392 |
+ |
}
|
|
393 |
+ |
|
|
394 |
+ |
pub fn route_layer<L>(self, layer: L) -> Self
|
|
395 |
+ |
where
|
|
396 |
+ |
L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
|
|
397 |
+ |
L::Service:
|
|
398 |
+ |
tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
|
|
399 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Response:
|
|
400 |
+ |
IntoResponse + 'static,
|
|
401 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Error:
|
|
402 |
+ |
Into<std::convert::Infallible> + 'static,
|
|
403 |
+ |
<L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
|
|
404 |
+ |
{
|
|
405 |
+ |
Self(self.0.route_layer(layer))
|
|
406 |
+ |
}
|
|
407 |
+ |
|
|
408 |
+ |
/// Drop the structural envelope and return the underlying `Router<S>`.
|
|
409 |
+ |
/// Called once in `build_app` after all mutation routes have been
|
|
410 |
+ |
/// registered; downstream code may then attach global layers, mount
|
|
411 |
+ |
/// static-file services, and add GET-only routes.
|
|
412 |
+ |
pub fn finalize(self) -> Router<S> {
|
|
413 |
+ |
self.0
|
|
414 |
+ |
}
|
|
415 |
+ |
}
|
|
416 |
+ |
|
|
417 |
+ |
/// Standard CSRF validation: header `X-CSRF-Token` first, then form-body
|
|
418 |
+ |
/// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes
|
|
419 |
+ |
/// and by the path-allowlist fallback during the L2 migration.
|
|
420 |
+ |
async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
|
| 176 |
421 |
|
// Get session from extensions
|
| 177 |
422 |
|
let session = match request.extensions().get::<Session>() {
|
| 178 |
423 |
|
Some(s) => s.clone(),
|
| 225 |
470 |
|
// forms (uploads go through HTMX + fetch, which attach
|
| 226 |
471 |
|
// `X-CSRF-Token` on the header path above), so rejecting here is
|
| 227 |
472 |
|
// the explicit boundary. If multipart adoption ever becomes
|
| 228 |
|
- |
// necessary, add a content-type branch that streams the body
|
| 229 |
|
- |
// through a multipart parser instead of naive `to_bytes`.
|
|
473 |
+ |
// necessary, register the route with `post_csrf_manual` and have
|
|
474 |
+ |
// the handler stream the body through a multipart parser before
|
|
475 |
+ |
// calling `validate_token_consuming`.
|
| 230 |
476 |
|
// - `application/json` and others must use the `X-CSRF-Token`
|
| 231 |
477 |
|
// header — anything that can set a custom header can set this one.
|
| 232 |
478 |
|
let content_type = request
|
| 438 |
684 |
|
assert!(!constant_time_compare(&token, &tampered));
|
| 439 |
685 |
|
}
|
| 440 |
686 |
|
|
|
687 |
+ |
#[test]
|
|
688 |
+ |
fn csrf_manually_validated_marker_is_zero_sized() {
|
|
689 |
+ |
assert_eq!(std::mem::size_of::<CsrfManuallyValidated>(), 0);
|
|
690 |
+ |
}
|
|
691 |
+ |
|
|
692 |
+ |
#[test]
|
|
693 |
+ |
fn csrf_posture_is_copyable_and_carries_reason() {
|
|
694 |
+ |
let p = CsrfPosture::Skip("webhook: stripe signature");
|
|
695 |
+ |
let copy = p;
|
|
696 |
+ |
match copy {
|
|
697 |
+ |
CsrfPosture::Skip(r) => assert_eq!(r, "webhook: stripe signature"),
|
|
698 |
+ |
_ => panic!("variant mismatch"),
|
|
699 |
+ |
}
|
|
700 |
+ |
}
|
|
701 |
+ |
|
| 441 |
702 |
|
#[test]
|
| 442 |
703 |
|
fn test_constant_time_compare_truncated() {
|
| 443 |
704 |
|
use crate::helpers::constant_time_compare;
|