Skip to main content

max / makenotwork

server: adopt lint block, fix clippy, fmt Green under 'SQLX_OFFLINE=true cargo clippy --all-targets -- -D warnings': let-else, write!/writeln! over format!-push, digit separators, explicit imports, Option<&T> over &Option<T>, borrow by-value params, bool-params to structs, #[must_use], concrete Default types. Scoped #[allow]s (with reasons) only for an axum async handler, tri-state PATCH option_option, zero-sized capability-witness maps, test float compares, and a test fixture's field convention. No SQL/route/auth/serde-field changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 16:13 UTC
Commit: f82abccd4e883ec608bcfe2b8a2750ae12c1dd95
Parent: 7a2b0ec
495 files changed, +6327 insertions, -7102 deletions
@@ -52,12 +52,12 @@ pub enum AppError {
52 52 **Rules:**
53 53 - Use `?` for error propagation. Never `.unwrap()` in production code.
54 54 - Use `.ok_or(AppError::NotFound)?` when an optional DB result must exist.
55 - - Use `AppError::BadRequest("message")` for user-caused errors — the string is shown directly.
55 + - Use `AppError::BadRequest("message")` for user-caused errors. The string is shown directly.
56 56 - Use `AppError::Validation("message")` for form validation failures (returns 422).
57 57 - Never expose internal error details to users. `Database` and `Internal` variants always show "Something went wrong."
58 58 - Convert external errors with `From` impls, not string formatting. Add `#[from]` to AppError variants for automatic conversion.
59 59
60 - On API routes (`/api/*`), a middleware layer (`json_error_layer`) automatically converts HTML error responses to `{"error": "message"}` JSON. Handlers don't need to handle this — it's transparent.
60 + On API routes (`/api/*`), a middleware layer (`json_error_layer`) automatically converts HTML error responses to `{"error": "message"}` JSON. Handlers don't need to handle this. It's transparent.
61 61
62 62 ## Route Handlers
63 63
@@ -85,12 +85,12 @@ async fn handler_name(
85 85 - Every handler gets `#[tracing::instrument(skip_all, name = "...")]` for structured logging.
86 86 - Return type is always `Result<impl IntoResponse>`.
87 87 - Extract auth requirements via the type system: `AuthUser` (login required), `MaybeUser` (optional), `AdminUser` (admin only, returns 404 to hide admin routes from non-admins).
88 - - Askama templates implement `IntoResponse` — return the struct directly.
88 + - Askama templates implement `IntoResponse`. Return the struct directly.
89 89 - CSRF token goes into every template that renders forms.
90 90
91 91 ### HTMX Responses
92 92
93 - Full-page handlers extend `base.html` and include `session_user`, `csrf_token`, navigation, etc. HTMX handlers return partial templates — HTML fragments without the base layout.
93 + Full-page handlers extend `base.html` and include `session_user`, `csrf_token`, navigation, etc. HTMX handlers return partial templates (HTML fragments without the base layout).
94 94
95 95 ```rust
96 96 // Full page — extends base.html
@@ -161,7 +161,7 @@ pub async fn get_user_by_id(pool: &PgPool, id: UserId) -> Result<Option<DbUser>>
161 161 - Always use positional parameters (`$1`, `$2`, ...). Never interpolate values into SQL strings.
162 162 - Use `sqlx::query_as::<_, DbRow>` for typed results. Use `sqlx::query!` only when the macro's compile-time checking is needed.
163 163 - `.fetch_one()` when exactly one row expected (errors on zero), `.fetch_optional()` when zero or one, `.fetch_all()` for lists.
164 - - Newtype ID wrappers (`UserId`, `ProjectId`, etc.) work directly with `.bind()` — they implement sqlx's `Encode`/`Decode`.
164 + - Newtype ID wrappers (`UserId`, `ProjectId`, etc.) work directly with `.bind()`. They implement sqlx's `Encode`/`Decode`.
165 165 - Multi-line SQL uses `r#"..."#` raw strings.
166 166
167 167 ### DB Row Types vs View Types
@@ -197,7 +197,7 @@ All database IDs are newtype wrappers defined via the `define_pg_uuid_id!` macro
197 197 define_pg_uuid_id!(UserId, ProjectId, ItemId, VersionId, /* ... */);
198 198 ```
199 199
200 - This generates `UserId(Uuid)` with `Display`, `FromStr`, `sqlx::Type`, `Encode`, `Decode`, `Serialize`, `Deserialize`, and `Default` (generates new v4 UUID). Use these everywhere — never pass raw `Uuid` or `String` for IDs.
200 + This generates `UserId(Uuid)` with `Display`, `FromStr`, `sqlx::Type`, `Encode`, `Decode`, `Serialize`, `Deserialize`, and `Default` (generates new v4 UUID). Use these everywhere. Never pass raw `Uuid` or `String` for IDs.
201 201
202 202 ### String Enums
203 203
@@ -229,7 +229,7 @@ Migrations live in `server/migrations/` and are numbered sequentially (e.g., `00
229 229 - Create indexes after the table definition, in the same migration.
230 230 - Prefer additive migrations (add columns, add tables). Destructive changes (drop columns, rename tables) need careful planning.
231 231 - Name migrations descriptively: `NNN_what_it_does.sql`.
232 - - **Indexes on growth tables must be `CONCURRENTLY`.** A plain `CREATE INDEX` takes an `ACCESS EXCLUSIVE` lock and blocks writes for the whole build — fine on a small table, a production write-stall on `transactions`/`page_views`/`subscriptions`/etc. `CREATE INDEX CONCURRENTLY` cannot run inside a transaction, so a migration that uses it **must start with the exact line `-- no-transaction`** (sqlx runs that file outside its per-migration transaction). Note the trade-off: a `-- no-transaction` migration is not atomic, so keep it to the single concurrent index build.
232 + - **Indexes on growth tables must be `CONCURRENTLY`.** A plain `CREATE INDEX` takes an `ACCESS EXCLUSIVE` lock and blocks writes for the whole build: fine on a small table, a production write-stall on `transactions`/`page_views`/`subscriptions`/etc. `CREATE INDEX CONCURRENTLY` cannot run inside a transaction, so a migration that uses it **must start with the exact line `-- no-transaction`** (sqlx runs that file outside its per-migration transaction). Note the trade-off: a `-- no-transaction` migration is not atomic, so keep it to the single concurrent index build.
233 233
234 234 ```sql
235 235 -- no-transaction
@@ -237,7 +237,7 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_transactions_seller_created
237 237 ON transactions (seller_user_id, created_at DESC);
238 238 ```
239 239
240 - The `migration_hygiene` test (`tests/migration_hygiene.rs`) enforces both rules — concurrent-on-growth-table and `IF NOT EXISTS` — for every migration past the frozen high-water mark (historical migrations can't change: sqlx checksums applied files). Bump `HIGH_WATER` there only after deliberately reviewing the migrations you're grandfathering.
240 + The `migration_hygiene` test (`tests/migration_hygiene.rs`) enforces both rules (concurrent-on-growth-table and `IF NOT EXISTS`) for every migration past the frozen high-water mark (historical migrations can't change: sqlx checksums applied files). Bump `HIGH_WATER` there only after deliberately reviewing the migrations you're grandfathering.
241 241
242 242 ```sql
243 243 -- Example: 004_file_scan_status.sql
@@ -284,8 +284,8 @@ HTMX fragment templates do NOT extend `base.html`. They render standalone HTML f
284 284 ### Template Variables
285 285
286 286 Every full-page template needs at minimum:
287 - - `csrf_token: Option<String>` — for the CSRF meta tag
288 - - `session_user: Option<SessionUser>` — for the header (login state, avatar)
287 + - `csrf_token: Option<String>`, for the CSRF meta tag
288 + - `session_user: Option<SessionUser>`, for the header (login state, avatar)
289 289
290 290 ## Frontend Performance
291 291
@@ -459,11 +459,11 @@ On Astra, use `--test-threads=8` (or the `RUST_TEST_THREADS=8` env var) to avoid
459 459 - **Rust 2024 edition** (Rust 1.85+). Uses `gen` keyword restrictions and other 2024 features.
460 460 - No `.unwrap()` in production code. Use `?`, `.ok_or()`, or `unwrap_or_default()`.
461 461 - Prefer `Option::and_then`/`map` over `if let Some`/`match` for simple transforms.
462 - - File size guideline per root `CONTRIBUTING.md`: 500-line limit on branching logic, flat lists exempt. Route files follow the same rule — split into directory modules when they grow beyond 500 lines.
462 + - File size guideline per root `CONTRIBUTING.md`: 500-line limit on branching logic, flat lists exempt. Route files follow the same rule. Split into directory modules when they grow beyond 500 lines.
463 463
464 464 ## Dependencies
465 465
466 - Always use the latest stable release of every dependency. When upgrading introduces breaking API changes, update the code — never pin old versions to avoid migration work.
466 + Always use the latest stable release of every dependency. When upgrading introduces breaking API changes, update the code. Never pin old versions to avoid migration work.
467 467
468 468 ## Deployment
469 469
@@ -175,3 +175,35 @@ tempfile = "3"
175 175 proptest = "1"
176 176 wiremock = "0.6"
177 177 pom-contract = { path = "../shared/pom-contract" }
178 +
179 + [lints.rust]
180 + unused = "warn"
181 + unreachable_pub = "warn"
182 +
183 + [lints.clippy]
184 + pedantic = { level = "warn", priority = -1 }
185 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
186 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
187 + # else in `pedantic` stays a warning. Keep this block identical across repos.
188 + module_name_repetitions = "allow"
189 + # Doc lints. No docs-completeness push is underway.
190 + missing_errors_doc = "allow"
191 + missing_panics_doc = "allow"
192 + doc_markdown = "allow"
193 + # Numeric casts. Endemic and mostly intentional in size and byte math.
194 + cast_possible_truncation = "allow"
195 + cast_sign_loss = "allow"
196 + cast_precision_loss = "allow"
197 + cast_possible_wrap = "allow"
198 + cast_lossless = "allow"
199 + # Subjective structure and style nags. High churn, low signal.
200 + must_use_candidate = "allow"
201 + too_many_lines = "allow"
202 + struct_excessive_bools = "allow"
203 + similar_names = "allow"
204 + items_after_statements = "allow"
205 + single_match_else = "allow"
206 + # Frequent false-positives in TUI and router-heavy code.
207 + match_same_arms = "allow"
208 + unnecessary_wraps = "allow"
209 + type_complexity = "allow"
M server/build.rs +9 -12
@@ -14,11 +14,11 @@ fn main() {
14 14 .map(|s| s.trim().to_string())
15 15 .unwrap_or_default();
16 16
17 - println!("cargo::rustc-env=GIT_HASH={}", hash);
17 + println!("cargo::rustc-env=GIT_HASH={hash}");
18 18 // Only re-run when HEAD changes
19 19 println!("cargo::rerun-if-changed=.git/HEAD");
20 20
21 - // Compile the TypeScript frontend to static/dist/ (best-effort — see fn).
21 + // Compile the TypeScript frontend to static/dist/ (best-effort, see fn).
22 22 build_frontend();
23 23
24 24 // --- Static asset fingerprinting ---
@@ -35,7 +35,7 @@ fn main() {
35 35
36 36 let mut hasher = DefaultHasher::new();
37 37 for path in &static_files {
38 - println!("cargo::rerun-if-changed={}", path);
38 + println!("cargo::rerun-if-changed={path}");
39 39 if let Ok(content) = fs::read(path) {
40 40 content.hash(&mut hasher);
41 41 }
@@ -52,18 +52,17 @@ fn main() {
52 52 let partial = format!(
53 53 r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin>
54 54 <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin>
55 - <link rel="stylesheet" href="/static/style.css?v={v}">
55 + <link rel="stylesheet" href="/static/style.css?v={version}">
56 56 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
57 57 <script src="/static/htmx.min.js"></script>
58 - <script src="/static/upload.js?v={v}"></script>
59 - <script type="module" src="/static/dist/core/index.js?v={v}"></script>"#,
60 - v = version,
58 + <script src="/static/upload.js?v={version}"></script>
59 + <script type="module" src="/static/dist/core/index.js?v={version}"></script>"#,
61 60 );
62 61
63 62 write_if_changed(Path::new("templates/_head_assets.html"), &partial);
64 63
65 64 // Per-page island loader macro. Heavy/page-specific islands (media player,
66 - // uploader, …) load on the pages that use them via
65 + // uploader, ...) load on the pages that use them via
67 66 // `{% import "_island.html" as island %}{% call island::island("name") %}`,
68 67 // cache-busted by the same content hash as the head assets.
69 68 let island_partial = r#"{% macro island(name) -%}
@@ -76,9 +75,7 @@ fn main() {
76 75
77 76 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
78 77 fn write_if_changed(path: &Path, contents: &str) {
79 - let needs_write = fs::read_to_string(path)
80 - .map(|existing| existing != contents)
81 - .unwrap_or(true);
78 + let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
82 79 if needs_write {
83 80 fs::write(path, contents)
84 81 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
@@ -90,7 +87,7 @@ fn write_if_changed(path: &Path, contents: &str) {
90 87 ///
91 88 /// Self-contained: on a fresh checkout or a new build host (no `node_modules`)
92 89 /// it runs `npm ci` first, so there is no manual install gate before a deploy.
93 - /// Best-effort and non-fatal otherwise — an absent Node or a compile error only
90 + /// Best-effort and non-fatal otherwise, an absent Node or a compile error only
94 91 /// emits a `cargo::warning` and leaves the Rust build to succeed against
95 92 /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to
96 93 /// opt out entirely (e.g. a Node-less CI that doesn't need the JS).
@@ -10,7 +10,7 @@ use std::env;
10 10
11 11 fn main() {
12 12 let args: Vec<String> = env::args().collect();
13 - let password = args.get(1).map(|s| s.as_str()).unwrap_or("demo123");
13 + let password = args.get(1).map_or("demo123", std::string::String::as_str);
14 14
15 15 let salt = SaltString::generate(&mut OsRng);
16 16 let argon2 = Argon2::default();
@@ -19,6 +19,6 @@ fn main() {
19 19 .hash_password(password.as_bytes(), &salt)
20 20 .expect("Failed to hash password");
21 21
22 - println!("Password: {}", password);
23 - println!("Hash: {}", hash);
22 + println!("Password: {password}");
23 + println!("Hash: {hash}");
24 24 }
@@ -12,7 +12,7 @@
12 12 -- Backfill each artifact from its parent release's signature. For a
13 13 -- single-platform release this is exactly correct; for the buggy multi-platform
14 14 -- case it preserves the current (wrong-for-the-second-platform) behavior rather
15 - -- than blanking it — re-publishing the affected artifact writes the right one.
15 + -- than blanking it. Re-publishing the affected artifact writes the right one.
16 16 -- ota_releases.signature is left in place (forward-only migrations, live rows)
17 17 -- but is no longer read or written; it is dead after this migration.
18 18
@@ -2,7 +2,7 @@
2 2 --
3 3 -- Schema only (phase 2 / p2-tables). The group-scoped push/pull and membership
4 4 -- gating that USE the `group_id` column below land in p2-changelog. The server
5 - -- never sees the Group Content Key (GCK) or any plaintext — it stores opaque
5 + -- never sees the Group Content Key (GCK) or any plaintext; it stores opaque
6 6 -- sealed grants and ciphertext only. Design: wiki synckit-groups-design.
7 7
8 8 -- A group: a shared changelog owned by one admin, whose members each hold a
@@ -3,7 +3,7 @@
3 3 -- Supersedes the sync_log.group_id column added in 171. Keeping group entries in
4 4 -- sync_log meant every personal-scope query (pull, key rotation, status, cleanup)
5 5 -- had to remember `AND group_id IS NULL` or silently leak GCK-encrypted group rows
6 - -- into a user's personal sync -- and let personal key rotation re-encrypt them with
6 + -- into a user's personal sync, and let personal key rotation re-encrypt them with
7 7 -- the wrong key. A dedicated table makes that isolation structural: sync_log stays
8 8 -- purely personal and unchanged, and the group changelog evolves on its own.
9 9 -- Design: wiki synckit-groups-design.
@@ -4,7 +4,7 @@ Makenot.work supports two-factor authentication (2FA) to protect your account. Y
4 4
5 5 ## Passkeys
6 6
7 - Passkeys use WebAuthn to let you log in with a fingerprint, face scan, hardware key, or device PIN -- no password needed.
7 + Passkeys use WebAuthn to let you log in with a fingerprint, face scan, hardware key, or device PIN. No password needed.
8 8
9 9 ### Setting Up a Passkey
10 10
@@ -3,14 +3,14 @@
3 3 //! When enabled, the entire site is reachable only by logged-in users who hold
4 4 //! a creator account or an active Fan+ subscription; everyone else is bounced
5 5 //! to `/login` with a notice. This backs the testnot.work staging mirror, whose
6 - //! data is a daily restore of production — gating it to Fan+/creator accounts
6 + //! data is a daily restore of production, gating it to Fan+/creator accounts
7 7 //! keeps that mirror off the open internet (the "available to anyone with a
8 8 //! Fan+ or creator account" rule), matching the testnot Fan+ perk.
9 9 //!
10 10 //! It is a COARSE pre-filter: it reads the cached session flags only (no DB
11 11 //! query, no session-tracking revalidation). The per-route `AuthUser` extractor
12 12 //! still enforces full auth underneath, so the gate never relaxes real
13 - //! authorization — it only narrows who reaches the routes at all. Default-off,
13 + //! authorization, it only narrows who reaches the routes at all. Default-off,
14 14 //! so production (`AccessGate::Open`) is completely unaffected.
15 15
16 16 use axum::{
@@ -40,7 +40,7 @@ fn path_is_exempt(path: &str) -> bool {
40 40 .is_some_and(|rest| rest.starts_with('/'))
41 41 }
42 42
43 - // Authentication surface — without these the gate would lock out its own
43 + // Authentication surface, without these the gate would lock out its own
44 44 // login page and the assets/endpoints the login flow needs.
45 45 hit(path, "/login")
46 46 || hit(path, "/logout")
M server/src/auth.rs +33 -41
@@ -13,8 +13,8 @@
13 13 //! when enabled.
14 14 //!
15 15 //! Extractors: [`AuthUser`] (required login), [`MaybeUserUnverified`] (optional,
16 - //! no revocation check — public read-only pages only), [`MaybeUserVerified`]
17 - //! (optional with revocation check — anywhere identity actually gates behavior),
16 + //! no revocation check, public read-only pages only), [`MaybeUserVerified`]
17 + //! (optional with revocation check, anywhere identity actually gates behavior),
18 18 //! [`AdminUser`] (admin-only, hides routes with 404).
19 19
20 20 use argon2::{
@@ -165,29 +165,25 @@ impl FromRequestParts<crate::AppState> for AuthUser {
165 165 // Every live session carries a tracking id, set at login by
166 166 // `track_session`. A session with USER_SESSION_KEY but no
167 167 // SESSION_TRACKING_KEY is a legacy pre-tracking session that cannot be
168 - // revoked — "log out everywhere", suspend, and password-change all act
168 + // revoked, "log out everywhere", suspend, and password-change all act
169 169 // on `user_sessions` rows it doesn't have. Refuse it (force re-login)
170 170 // rather than trust an unrevocable session (Run 20 Security). Matches
171 171 // the short-circuit `MaybeUserUnverified` already applies.
172 172 let mut user = user;
173 - let tracking_id = match session.get::<UserSessionId>(SESSION_TRACKING_KEY).await {
174 - Ok(Some(id)) => id,
175 - _ => {
176 - let _ = session.flush().await;
177 - return Err(AppError::Unauthorized);
178 - }
173 + let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await else {
174 + let _ = session.flush().await;
175 + return Err(AppError::Unauthorized);
179 176 };
180 177
181 178 // Validate the tracking row. Uses an in-memory cache to avoid hitting
182 - // the DB on every request — if this session was validated within
179 + // the DB on every request, if this session was validated within
183 180 // SESSION_TOUCH_CACHE_SECS, skip the query.
184 181 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
185 182 let cached = state
186 183 .caches
187 184 .session_cache
188 185 .get(&tracking_id)
189 - .map(|entry| entry.elapsed() < cache_ttl)
190 - .unwrap_or(false);
186 + .is_some_and(|entry| entry.elapsed() < cache_ttl);
191 187
192 188 if !cached {
193 189 let result = match db::sessions::touch_session(&state.db, tracking_id).await {
@@ -240,9 +236,9 @@ impl FromRequestParts<crate::AppState> for AuthUser {
240 236 }
241 237 }
242 238
243 - /// Extractor for optional authenticated users — returns None if not logged in.
239 + /// Extractor for optional authenticated users, returns None if not logged in.
244 240 ///
245 - /// **DANGER — this extractor does NOT validate the session against the database.**
241 + /// **DANGER, this extractor does NOT validate the session against the database.**
246 242 /// A revoked session (user clicked "log out everywhere", account suspended,
247 243 /// session row deleted) will still resolve to `Some(SessionUser)` here until
248 244 /// the cookie naturally expires. The name carries the warning: any handler
@@ -279,7 +275,7 @@ where
279 275
280 276 // Short-circuit legacy sessions (USER_SESSION_KEY present without a
281 277 // SESSION_TRACKING_KEY) to anonymous. Without this, a pre-tracking
282 - // session quietly survives `/logout-everywhere` — that sweep deletes
278 + // session quietly survives `/logout-everywhere`, that sweep deletes
283 279 // user_sessions rows, but a legacy session has no row to delete and
284 280 // would keep rendering as logged-in on every Unverified extractor
285 281 // until the cookie naturally expires.
@@ -305,7 +301,7 @@ where
305 301 ///
306 302 /// Costs one cached `touch_session` query per request (TTL = `SESSION_TOUCH_CACHE_SECS`).
307 303 /// Prefer this over `MaybeUserUnverified` anywhere the identity actually gates
308 - /// behavior — paid content access, OAuth flows, download grants, comments,
304 + /// behavior, paid content access, OAuth flows, download grants, comments,
309 305 /// or anything that writes to the DB on behalf of the user.
310 306 pub struct MaybeUserVerified(pub Option<SessionUser>);
311 307
@@ -333,12 +329,9 @@ impl FromRequestParts<crate::AppState> for MaybeUserVerified {
333 329 // pre-tracking session that can't be revoked; treat it as anonymous
334 330 // rather than trust it (Run 20 Security), matching `AuthUser` and
335 331 // `MaybeUserUnverified`.
336 - let tracking_id = match session.get::<UserSessionId>(SESSION_TRACKING_KEY).await {
337 - Ok(Some(id)) => id,
338 - _ => {
339 - let _ = session.flush().await;
340 - return Ok(MaybeUserVerified(None));
341 - }
332 + let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await else {
333 + let _ = session.flush().await;
334 + return Ok(MaybeUserVerified(None));
342 335 };
343 336
344 337 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
@@ -346,8 +339,7 @@ impl FromRequestParts<crate::AppState> for MaybeUserVerified {
346 339 .caches
347 340 .session_cache
348 341 .get(&tracking_id)
349 - .map(|entry| entry.elapsed() < cache_ttl)
350 - .unwrap_or(false);
342 + .is_some_and(|entry| entry.elapsed() < cache_ttl);
351 343
352 344 if !cached {
353 345 let result = match db::sessions::touch_session(&state.db, tracking_id).await {
@@ -413,12 +405,12 @@ impl AdminId {
413 405 }
414 406
415 407 /// Mint an `AdminId` from the configured `ADMIN_USER_ID` for out-of-band admin
416 - /// contexts that have no HTTP session — specifically the `mnw-admin` CLI, which
408 + /// contexts that have no HTTP session, specifically the `mnw-admin` CLI, which
417 409 /// loads the same server env. Returns `None` when no admin is configured.
418 410 ///
419 411 /// This is the only constructor besides [`AdminUser::admin_id`], and it is
420 412 /// gated on the exact same config value that [`require_admin`] checks, so it
421 - /// cannot attribute a moderation action to a non-admin — preserving the
413 + /// cannot attribute a moderation action to a non-admin, preserving the
422 414 /// forgery-proof invariant while letting a headless admin tool stamp the audit
423 415 /// trail with the real actor instead of skipping it.
424 416 pub fn from_config(config: &crate::config::Config) -> Option<Self> {
@@ -434,7 +426,7 @@ pub struct AdminUser(pub SessionUser);
434 426
435 427 impl AdminUser {
436 428 /// Mint the [`AdminId`] witness for this verified admin. The only way to
437 - /// obtain an `AdminId` — its private field can't be constructed elsewhere.
429 + /// obtain an `AdminId`, its private field can't be constructed elsewhere.
438 430 pub fn admin_id(&self) -> AdminId {
439 431 AdminId(self.0.id)
440 432 }
@@ -543,7 +535,7 @@ impl FromRequestParts<crate::AppState> for AlertsAuth {
543 535 /// during `ssh-key-lookup` (after authenticating the user by SSH key) and the
544 536 /// CLI forwards. Because the assertion is keyed by the server-only
545 537 /// `signing_secret`, a leaked `ServiceAuth` token cannot forge one for another
546 - /// user — so internal handlers derive identity from this, never from a
538 + /// user, so internal handlers derive identity from this, never from a
547 539 /// caller-supplied `user_id` field. Handlers use `actor.user_id()` for scoping
548 540 /// and `actor.ensure_owns(resource.user_id)?` for ownership checks.
549 541 pub struct InternalActor(pub UserId);
@@ -590,7 +582,7 @@ impl FromRequestParts<crate::AppState> for InternalActor {
590 582 /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms).
591 583 /// Verification auto-detects params from the hash string, so no feature flag needed there.
592 584 /// Synchronous Argon2id hash. CPU-bound (hundreds of ms); do NOT call from an
593 - /// async handler — use [`hash_password_async`], which runs this on a blocking
585 + /// async handler, use [`hash_password_async`], which runs this on a blocking
594 586 /// thread so a burst of signups can't starve the Tokio worker pool. The sync
595 587 /// form remains `pub` only for one-time `DUMMY_HASH` initializers and
596 588 /// test/integration fixtures that seed password hashes off the request path.
@@ -619,18 +611,18 @@ pub fn hash_password(password: &str) -> Result<String, AppError> {
619 611 /// the parsed hash, not the instance) but explicit derivation pins our
620 612 /// boundary: this function only verifies Argon2 family hashes, anything
621 613 /// else fails out at `Algorithm::try_from`. Forward-compatible with a
622 - /// future algorithm migration — when one lands, add a dispatch table
614 + /// future algorithm migration, when one lands, add a dispatch table
623 615 /// instead of swapping the default instance under the verifier's feet.
624 616 ///
625 - /// CPU-bound (hundreds of ms); do NOT call from an async handler — use
617 + /// CPU-bound (hundreds of ms); do NOT call from an async handler, use
626 618 /// [`verify_password_async`], which runs this on a blocking thread so concurrent
627 619 /// logins can't starve the Tokio worker pool. Kept `pub(crate)` for the async
628 620 /// wrapper, the timing-equalizer dummy verifies, and tests.
629 621 pub(crate) fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
630 622 // A stored hash that won't parse (corruption, or a non-Argon2 algorithm we
631 623 // don't verify) is a server-side data problem, not a 500 for the user: treat
632 - // it as a non-match so login simply fails, and log it for ops. Returning an
633 - // Internal error here would also be a (third-order) account oracle — it
624 + // it as a non-match so login fails, and log it for ops. Returning an
625 + // Internal error here would also be a (third-order) account oracle, it
634 626 // distinguishes "valid account, bad stored hash" from "valid account, wrong
635 627 // password" by status code (SEC minor, Run #23).
636 628 let reject = |what: &str, e: &dyn std::fmt::Display| {
@@ -691,13 +683,13 @@ pub enum LoginGate {
691 683 }
692 684
693 685 /// Uniform password + account-status gate for relying-party logins (OAuth
694 - /// authorize, SyncKit auth) — the flows that reject 2FA accounts outright.
686 + /// authorize, SyncKit auth), the flows that reject 2FA accounts outright.
695 687 ///
696 688 /// Runs Argon2 first, then folds *every* refusal reason (wrong password,
697 689 /// suspended, deactivated, locked, 2FA-enabled) into a single accounted
698 690 /// decision: a denial always increments the failed-login counter, a success
699 691 /// always resets it. Collapsing the blocked-account cases into the wrong-password
700 - /// path is what stops the counter from becoming a confirmed-password oracle — a
692 + /// path is what stops the counter from becoming a confirmed-password oracle, a
701 693 /// correct guess against a 2FA/suspended account must be indistinguishable from a
702 694 /// wrong one (ultra-fuzz Run 3 / Run 11 Sec M1). Both relying parties call this
703 695 /// instead of open-coding the ordering, so the invariant lives in one place.
@@ -811,7 +803,7 @@ pub async fn track_session(
811 803
812 804 /// Send a new-device login notification if the user has other active sessions.
813 805 ///
814 - /// Fire-and-forget — spawns a background task. Only sends if the user has opted in
806 + /// Fire-and-forget, spawns a background task. Only sends if the user has opted in
815 807 /// and has more than one active session (meaning this is a new device).
816 808 #[allow(clippy::too_many_arguments)]
817 809 pub async fn maybe_send_login_notification(
@@ -880,7 +872,7 @@ pub async fn maybe_send_login_notification(
880 872 /// Returns Some(count) if breached, None if clean or API unavailable.
881 873 ///
882 874 /// This check is advisory (it never blocks a password change), so a lookup
883 - /// failure fails open — but it must not fail *silently*. A network blip or
875 + /// failure fails open, but it must not fail *silently*. A network blip or
884 876 /// HIBP outage that disables breach checking is logged at WARN so the gap is
885 877 /// visible in observability rather than disappearing into a bare `?`.
886 878 pub async fn check_password_breach(password: &str) -> Option<u64> {
@@ -889,7 +881,7 @@ pub async fn check_password_breach(password: &str) -> Option<u64> {
889 881 let hash = hex::encode(Sha1::digest(password.as_bytes())).to_uppercase();
890 882 let (prefix, suffix) = hash.split_at(5);
891 883
892 - let url = format!("https://api.pwnedpasswords.com/range/{}", prefix);
884 + let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
893 885 let response = match crate::helpers::HTTP_CLIENT
894 886 .get(&url)
895 887 .header("User-Agent", "MakeNotWork-Security-Check")
@@ -959,7 +951,7 @@ mod tests {
959 951 #[test]
960 952 fn verify_password_unparseable_hash_is_non_match_not_error() {
961 953 // A corrupt / non-Argon2 stored hash must fail login cleanly (Ok(false)),
962 - // not 500 — avoids an availability bug and an account oracle (SEC, Run #23).
954 + // not 500, avoids an availability bug and an account oracle (SEC, Run #23).
963 955 for bad in [
964 956 "",
965 957 "not-a-phc-string",
@@ -1048,7 +1040,7 @@ mod tests {
1048 1040 }
1049 1041
1050 1042 #[tokio::test]
1051 - #[ignore] // Requires network access — run manually
1043 + #[ignore = "requires network access, run manually"]
1052 1044 async fn check_password_breach_known_breached() {
1053 1045 let result = check_password_breach("password").await;
1054 1046 assert!(result.is_some());
@@ -1056,7 +1048,7 @@ mod tests {
1056 1048 }
1057 1049
1058 1050 #[tokio::test]
1059 - #[ignore] // Requires network access — run manually
1051 + #[ignore = "requires network access, run manually"]
1060 1052 async fn check_password_breach_unknown() {
1061 1053 // A random 64-char string should not appear in any breach database
1062 1054 let random_pw = "xK9m2Qp7vL4nR8wJ3sY6dF1gH5bT0cU9eA2iO7lN4mP8qW3rX6zV1yB5jD0fG";
@@ -1,7 +1,7 @@
1 1 //! Bounded background-task queue for fire-and-forget work.
2 2 //!
3 3 //! Replaces per-request `tokio::spawn(...)` for low-priority work that
4 - //! competes with request handlers for the DB pool — email sends, mailing-list
4 + //! competes with request handlers for the DB pool, email sends, mailing-list
5 5 //! subscriptions, etc. Run #4 fixed the same shape for page views via
6 6 //! `db::page_views::PageViewTx`; Run #8 surfaced it again on the webhook
7 7 //! hot path (`routes/stripe/webhook/checkout_helpers.rs`), so this module
@@ -62,7 +62,7 @@ impl BackgroundTx {
62 62 /// pulls tasks off the channel and runs each under a semaphore permit so
63 63 /// concurrent execution is bounded.
64 64 ///
65 - /// On shutdown (the `shutdown` watch fires — a value change or all senders
65 + /// On shutdown (the `shutdown` watch fires, a value change or all senders
66 66 /// dropped) the drainer stops taking new work, runs every already-queued task,
67 67 /// then waits for in-flight tasks to finish before exiting. Without this the
68 68 /// `bg` pool was the one undrained primitive: in-flight emails / cache purges /
@@ -1,6 +1,6 @@
1 1 //! CLI tool for MNW admin operations (waitlist, waves, creator management).
2 2 //!
3 - //! Connects directly to the database — no HTTP server needed.
3 + //! Connects directly to the database, no HTTP server needed.
4 4 //!
5 5 //! Usage:
6 6 //! mnw-admin waitlist List pending applications
@@ -171,7 +171,7 @@ async fn main() -> anyhow::Result<()> {
171 171 Command::Storage { username } => cmd_storage(&pool, &username).await?,
172 172 Command::RebuildKeys => cmd_rebuild_keys(&pool).await?,
173 173 Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?,
174 - Command::InstallHooks => cmd_install_hooks().await?,
174 + Command::InstallHooks => cmd_install_hooks()?,
175 175 Command::BackfillGitConfig => cmd_backfill_git_config()?,
176 176 Command::SetupGit => cmd_setup_git()?,
177 177 }
@@ -216,16 +216,16 @@ async fn cmd_approve(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
216 216
217 217 let user = db::users::get_user_by_username(pool, &username)
218 218 .await?
219 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
219 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
220 220
221 221 if user.can_create_projects {
222 - println!("'{}' already has creator access.", username_str);
222 + println!("'{username_str}' already has creator access.");
223 223 return Ok(());
224 224 }
225 225
226 226 let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id)
227 227 .await?
228 - .ok_or_else(|| anyhow::anyhow!("'{}' has no waitlist entry", username_str))?;
228 + .ok_or_else(|| anyhow::anyhow!("'{username_str}' has no waitlist entry"))?;
229 229
230 230 db::waitlist::update_waitlist_status(
231 231 pool,
@@ -238,7 +238,7 @@ async fn cmd_approve(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
238 238
239 239 db::waitlist::grant_creator_access(pool, user.id).await?;
240 240
241 - println!("Approved '{}' and granted creator access.", username_str);
241 + println!("Approved '{username_str}' and granted creator access.");
242 242 Ok(())
243 243 }
244 244
@@ -248,15 +248,15 @@ async fn cmd_spam(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
248 248
249 249 let user = db::users::get_user_by_username(pool, &username)
250 250 .await?
251 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
251 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
252 252
253 253 let entry = db::waitlist::get_waitlist_entry_by_user(pool, user.id)
254 254 .await?
255 - .ok_or_else(|| anyhow::anyhow!("'{}' has no waitlist entry", username_str))?;
255 + .ok_or_else(|| anyhow::anyhow!("'{username_str}' has no waitlist entry"))?;
256 256
257 257 db::waitlist::update_waitlist_status(pool, entry.id, WaitlistStatus::Spam, None, None).await?;
258 258
259 - println!("Marked '{}' as spam.", username_str);
259 + println!("Marked '{username_str}' as spam.");
260 260 Ok(())
261 261 }
262 262
@@ -271,8 +271,7 @@ async fn cmd_wave(pool: &PgPool, lottery_count: i32) -> anyhow::Result<()> {
271 271 let eligible = db::waitlist::get_lottery_eligible_count(pool).await?;
272 272
273 273 println!(
274 - "Wave #{}: {} hand-pick(s), drawing {} from {} eligible.",
275 - next_wave, hand_picked_count, lottery_count, eligible
274 + "Wave #{next_wave}: {hand_picked_count} hand-pick(s), drawing {lottery_count} from {eligible} eligible."
276 275 );
277 276 print!("Proceed? [y/N] ");
278 277
@@ -318,8 +317,8 @@ async fn cmd_wave(pool: &PgPool, lottery_count: i32) -> anyhow::Result<()> {
318 317
319 318 tx.commit().await?;
320 319
321 - println!("\nWave #{} complete.", wave_number);
322 - println!(" Hand-picks assigned: {}", assigned);
320 + println!("\nWave #{wave_number} complete.");
321 + println!(" Hand-picks assigned: {assigned}");
323 322 println!(" Lottery winners: {}", winners.len());
324 323
325 324 if !winners.is_empty() {
@@ -344,7 +343,7 @@ async fn cmd_stats(pool: &PgPool) -> anyhow::Result<()> {
344 343 println!(" Approved: {}", stats.approved);
345 344 println!(" Spam: {}", stats.spam);
346 345 println!();
347 - println!("Creators: {}", total_creators);
346 + println!("Creators: {total_creators}");
348 347 println!("Waves: {}", waves.len());
349 348
350 349 Ok(())
@@ -355,7 +354,7 @@ async fn cmd_stats(pool: &PgPool) -> anyhow::Result<()> {
355 354 /// Build the collaborators the shared moderation service needs, from the same
356 355 /// server env the CLI already loaded (`/etc/mnw/makenotwork.env`).
357 356 ///
358 - /// Fails if no admin is configured — we refuse to issue a moderation action with
357 + /// Fails if no admin is configured, we refuse to issue a moderation action with
359 358 /// no admin actor to attribute the audit record to, mirroring the web
360 359 /// `require_admin` gate for a headless caller. Building the Stripe + email clients
361 360 /// here (not just the DB pool) is the whole point of routing the CLI through the
@@ -393,10 +392,10 @@ async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow:
393 392
394 393 let user = db::users::get_user_by_username(pool, &username)
395 394 .await?
396 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
395 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
397 396
398 397 if user.is_suspended() {
399 - println!("'{}' is already suspended.", username_str);
398 + println!("'{username_str}' is already suspended.");
400 399 return Ok(());
401 400 }
402 401
@@ -412,7 +411,7 @@ async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow:
412 411 )
413 412 .await?;
414 413
415 - println!("Suspended '{}'. Reason: {}", username_str, reason);
414 + println!("Suspended '{username_str}'. Reason: {reason}");
416 415 Ok(())
417 416 }
418 417
@@ -422,10 +421,10 @@ async fn cmd_unsuspend(pool: &PgPool, username_str: &str) -> anyhow::Result<()>
422 421
423 422 let user = db::users::get_user_by_username(pool, &username)
424 423 .await?
425 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
424 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
426 425
427 426 if !user.is_suspended() {
428 - println!("'{}' is not suspended.", username_str);
427 + println!("'{username_str}' is not suspended.");
429 428 return Ok(());
430 429 }
431 430
@@ -438,7 +437,7 @@ async fn cmd_unsuspend(pool: &PgPool, username_str: &str) -> anyhow::Result<()>
438 437 )
439 438 .await?;
440 439
441 - println!("Unsuspended '{}'.", username_str);
440 + println!("Unsuspended '{username_str}'.");
442 441 Ok(())
443 442 }
444 443
@@ -461,12 +460,10 @@ async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> {
461 460 for user in &users {
462 461 let suspended = user
463 462 .suspended_at
464 - .map(|t| t.format("%Y-%m-%d").to_string())
465 - .unwrap_or_else(|| "-".to_string());
463 + .map_or_else(|| "-".to_string(), |t| t.format("%Y-%m-%d").to_string());
466 464 let appeal_date = user
467 465 .appeal_submitted_at
468 - .map(|t| t.format("%Y-%m-%d").to_string())
469 - .unwrap_or_else(|| "-".to_string());
466 + .map_or_else(|| "-".to_string(), |t| t.format("%Y-%m-%d").to_string());
470 467 let appeal = user.appeal_text.as_deref().unwrap_or("");
471 468 let appeal_short = if appeal.len() > 50 {
472 469 format!("{}...", truncate_display(appeal, 50))
@@ -494,13 +491,10 @@ async fn cmd_decide(
494 491
495 492 let user = db::users::get_user_by_username(pool, &username)
496 493 .await?
497 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
494 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
498 495
499 496 let decision: AppealDecision = decision_str.parse().map_err(|_| {
500 - anyhow::anyhow!(
501 - "invalid decision '{}': use 'approved' or 'denied'",
502 - decision_str
503 - )
497 + anyhow::anyhow!("invalid decision '{decision_str}': use 'approved' or 'denied'")
504 498 })?;
505 499
506 500 let response = response.trim();
@@ -522,10 +516,10 @@ async fn cmd_decide(
522 516
523 517 match decision {
524 518 AppealDecision::Approved => {
525 - println!("Appeal approved for '{}'. Suspension lifted.", username_str);
519 + println!("Appeal approved for '{username_str}'. Suspension lifted.");
526 520 }
527 521 AppealDecision::Denied => {
528 - println!("Appeal denied for '{}'. Suspension remains.", username_str);
522 + println!("Appeal denied for '{username_str}'. Suspension remains.");
529 523 }
530 524 }
531 525 Ok(())
@@ -542,8 +536,8 @@ async fn cmd_revenue(pool: &PgPool) -> anyhow::Result<()> {
542 536 " Total revenue: {}",
543 537 makenotwork::formatting::format_revenue(revenue_cents)
544 538 );
545 - println!(" Total sales: {}", completed);
546 - println!(" Total refunds: {}", refunded);
539 + println!(" Total sales: {completed}");
540 + println!(" Total refunds: {refunded}");
547 541
548 542 Ok(())
549 543 }
@@ -554,12 +548,12 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<(
554 548
555 549 let user = db::users::get_user_by_username(pool, &username)
556 550 .await?
557 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
551 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
558 552
559 553 let txs = db::transactions::get_transactions_by_seller(pool, user.id, Some(50)).await?;
560 554
561 555 if txs.is_empty() {
562 - println!("No transactions for '{}'.", username_str);
556 + println!("No transactions for '{username_str}'.");
563 557 return Ok(());
564 558 }
565 559
@@ -604,7 +598,7 @@ async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
604 598
605 599 let user = db::users::get_user_by_username(pool, &username)
606 600 .await?
607 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
601 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
608 602
609 603 let rows = db::transactions::get_seller_transactions_for_export(pool, user.id).await?;
610 604
@@ -635,13 +629,13 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
635 629
636 630 let user = db::users::get_user_by_username(pool, &username)
637 631 .await?
638 - .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
632 + .ok_or_else(|| anyhow::anyhow!("user '{username_str}' not found"))?;
639 633
640 634 let item_keys = db::items::get_user_s3_keys(pool, user.id).await?;
641 635 let version_keys = db::versions::get_user_version_s3_keys(pool, user.id).await?;
642 636
643 637 if item_keys.is_empty() && version_keys.is_empty() {
644 - println!("No S3 files for '{}'.", username_str);
638 + println!("No S3 files for '{username_str}'.");
645 639 return Ok(());
646 640 }
647 641
@@ -682,16 +676,13 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
682 676 }
683 677
684 678 let version_file_count = version_keys.iter().filter(|r| r.s3_key.is_some()).count();
685 - println!(
686 - "\n{} item file(s), {} version file(s).",
687 - item_file_count, version_file_count
688 - );
679 + println!("\n{item_file_count} item file(s), {version_file_count} version file(s).");
689 680 Ok(())
690 681 }
691 682
692 683 // ── Build hooks command ──
693 684
694 - async fn cmd_install_hooks() -> anyhow::Result<()> {
685 + fn cmd_install_hooks() -> anyhow::Result<()> {
695 686 let token = std::env::var("BUILD_TRIGGER_TOKEN")
696 687 .map_err(|_| anyhow::anyhow!("BUILD_TRIGGER_TOKEN must be set"))?;
697 688
@@ -701,7 +692,7 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
701 692
702 693 let root = std::path::Path::new(&git_root);
703 694 if !root.exists() {
704 - anyhow::bail!("git root {} does not exist", git_root);
695 + anyhow::bail!("git root {git_root} does not exist");
705 696 }
706 697
707 698 for owner_entry in std::fs::read_dir(root)? {
@@ -714,7 +705,12 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
714 705 let repo_entry = repo_entry?;
715 706 let repo_path = repo_entry.path();
716 707 let repo_name = match repo_path.file_name().and_then(|n| n.to_str()) {
717 - Some(n) if n.ends_with(".git") => n.trim_end_matches(".git"),
708 + Some(n)
709 + if std::path::Path::new(n).extension().and_then(|e| e.to_str())
710 + == Some("git") =>
711 + {
712 + n.trim_end_matches(".git")
713 + }
718 714 _ => continue,
719 715 };
720 716 if !repo_path.is_dir() {
@@ -728,7 +724,7 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
728 724 }
729 725 }
730 726
731 - println!("Installed post-receive hooks on {} repo(s).", installed);
727 + println!("Installed post-receive hooks on {installed} repo(s).");
732 728 Ok(())
733 729 }
734 730
@@ -739,7 +735,7 @@ fn cmd_backfill_git_config() -> anyhow::Result<()> {
739 735 let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
740 736 let root = std::path::Path::new(&git_root);
741 737 if !root.exists() {
742 - anyhow::bail!("git root {} does not exist", git_root);
738 + anyhow::bail!("git root {git_root} does not exist");
743 739 }
744 740
745 741 let mut updated = 0u32;
@@ -754,7 +750,11 @@ fn cmd_backfill_git_config() -> anyhow::Result<()> {
754 750 let is_bare = repo_path
755 751 .file_name()
756 752 .and_then(|n| n.to_str())
757 - .is_some_and(|n| n.ends_with(".git"));
753 + .is_some_and(|n| {
754 + std::path::Path::new(n)
755 + .extension()
756 + .is_some_and(|e| e == "git")
757 + });
758 758 if !is_bare || !repo_path.is_dir() {
759 759 continue;
760 760 }
@@ -811,7 +811,12 @@ fn cmd_setup_git() -> anyhow::Result<()> {
811 811 }
812 812
813 813 // 4. Install sudoers rule
814 - if !sudoers_file.exists() {
814 + if sudoers_file.exists() {
815 + println!(
816 + "[setup] Sudoers rule already exists: {}",
817 + sudoers_file.display()
818 + );
819 + } else {
815 820 let rule = format!(
816 821 "makenotwork ALL=(git) NOPASSWD: {} rebuild-keys\n",
817 822 mnw_admin.display(),
@@ -830,11 +835,6 @@ fn cmd_setup_git() -> anyhow::Result<()> {
830 835 sudoers_file.display()
831 836 );
832 837 }
833 - } else {
834 - println!(
835 - "[setup] Sudoers rule already exists: {}",
836 - sudoers_file.display()
837 - );
838 838 }
839 839
840 840 println!("[setup] Git SSH infrastructure ready.");
@@ -857,7 +857,7 @@ fn chown(spec: &str, path: &std::path::Path) -> anyhow::Result<()> {
857 857 async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> {
858 858 let key_count = db::ssh_keys::get_all_keys_with_username(pool).await?.len();
859 859 makenotwork::git_ssh::write_authorized_keys(pool, true).await?;
860 - println!("Rebuilt authorized_keys with {} key(s).", key_count);
860 + println!("Rebuilt authorized_keys with {key_count} key(s).");
861 861 Ok(())
862 862 }
863 863
@@ -1,4 +1,4 @@
1 - //! Build runner — dispatches and executes OTA builds via SSH to remote hosts.
1 + //! Build runner, dispatches and executes OTA builds via SSH to remote hosts.
2 2 //!
3 3 //! The scheduler calls `dispatch_pending_build()` each tick. If no build is
4 4 //! running and one is pending, it spawns a `tokio::spawn` task that SSHes to
@@ -66,7 +66,7 @@ async fn cleanup_remote_dir(host: &str, build_dir: &str) {
66 66 let cmd = format!("rm -rf {}", shell_escape(build_dir));
67 67 let _ = tokio::time::timeout(
68 68 Duration::from_secs(SSH_CLEANUP_TIMEOUT_SECS),
69 - run_ssh_command(host, &cmd),
69 + Box::pin(run_ssh_command(host, &cmd)),
70 70 )
71 71 .await;
72 72 }
@@ -154,7 +154,7 @@ fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Opt
154 154
155 155 /// Check for a pending build and spawn it if no build is currently running.
156 156 ///
157 - /// Called from the scheduler loop. Non-blocking — spawns the build task and returns.
157 + /// Called from the scheduler loop. Non-blocking, spawns the build task and returns.
158 158 #[tracing::instrument(skip_all, name = "build_runner::dispatch")]
159 159 pub async fn dispatch_pending_build(ctx: &BuildCtx) {
160 160 // Recover from stale running builds (e.g. server crashed mid-build)
@@ -233,7 +233,7 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) {
233 233 // Resolve each target to its build host up front. Synchronous failures (bad
234 234 // target format, no host configured) are tallied here; resolvable targets are
235 235 // grouped by host so independent hosts (e.g. linux vs darwin) build
236 - // concurrently while same-host targets stay serial — a multi-target release no
236 + // concurrently while same-host targets stay serial, a multi-target release no
237 237 // longer serializes end to end at up to 30 min/target (Perf-S2, Run 9).
238 238 let mut groups: Vec<(String, Vec<(String, String)>)> = Vec::new(); // host -> [(os, arch)]
239 239 for target_str in &config.targets {
@@ -247,18 +247,15 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) {
247 247 continue;
248 248 };
249 249
250 - let host = match build_host_for_target(&ctx.config, target_os) {
251 - Some(h) => h,
252 - None => {
253 - let msg = format!("no build host for {target_os}, skipping {target_str}\n");
254 - tracing::warn!("{}", msg.trim());
255 - let _ = append_log_bounded(ctx, build.id, &msg).await;
256 - failed_count += 1;
257 - if first_error.is_none() {
258 - first_error = Some(format!("no build host for {target_os}"));
259 - }
260 - continue;
250 + let Some(host) = build_host_for_target(&ctx.config, target_os) else {
251 + let msg = format!("no build host for {target_os}, skipping {target_str}\n");
252 + tracing::warn!("{}", msg.trim());
253 + let _ = append_log_bounded(ctx, build.id, &msg).await;
254 + failed_count += 1;
255 + if first_error.is_none() {
256 + first_error = Some(format!("no build host for {target_os}"));
261 257 }
258 + continue;
262 259 };
263 260
264 261 let entry = (target_os.to_string(), arch.to_string());
@@ -281,7 +278,11 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) {
281 278 let mut oks: Vec<TargetArtifact> = Vec::new();
282 279 let mut errs: Vec<TargetError> = Vec::new();
283 280 for (target_os, arch) in &targets {
284 - match execute_target(&ctx, &build, &config, &host, target_os, arch).await {
281 + match Box::pin(execute_target(
282 + &ctx, &build, &config, &host, target_os, arch,
283 + ))
284 + .await
285 + {
285 286 Ok((s3_key, signature)) => {
286 287 oks.push((target_os.clone(), arch.clone(), s3_key, signature));
287 288 }
@@ -393,7 +394,7 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) {
393 394 .map(|app| app.creator_id);
394 395
395 396 // Record artifacts and enqueue each for malware scanning. The artifact stays
396 - // `pending` (not served) until the scan clears it — same gate as the item
397 + // `pending` (not served) until the scan clears it, same gate as the item
397 398 // channel.
398 399 for (target_os, arch, s3_key, signature) in &artifact_keys {
399 400 // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable)
@@ -520,7 +521,7 @@ async fn execute_target(
520 521 // Execute via SSH with timeout
521 522 let ssh_result = tokio::time::timeout(
522 523 Duration::from_secs(BUILD_TIMEOUT_SECS),
523 - run_ssh_command(host, &remote_script),
524 + Box::pin(run_ssh_command(host, &remote_script)),
524 525 )
525 526 .await;
526 527
@@ -528,11 +529,11 @@ async fn execute_target(
528 529 Ok(Ok(output)) => output,
529 530 Ok(Err(e)) => {
530 531 // Cleanup remote build dir (best-effort)
531 - cleanup_remote_dir(host, &build_dir).await;
532 + Box::pin(cleanup_remote_dir(host, &build_dir)).await;
532 533 return Err(format!("SSH command failed: {e}"));
533 534 }
534 535 Err(_) => {
535 - cleanup_remote_dir(host, &build_dir).await;
536 + Box::pin(cleanup_remote_dir(host, &build_dir)).await;
536 537 return Err("build timed out".to_string());
537 538 }
538 539 };
@@ -562,7 +563,7 @@ async fn execute_target(
562 563 run_scp_download(host, &format!("{scp_remote_path}.sig"), &local_sig_tmp).await;
563 564
564 565 // Cleanup remote build dir
565 - cleanup_remote_dir(host, &build_dir).await;
566 + Box::pin(cleanup_remote_dir(host, &build_dir)).await;
566 567
567 568 if let Err(e) = scp_result {
568 569 // The main artifact failed, but the .sig sidecar may already be on disk
@@ -585,7 +586,7 @@ async fn execute_target(
585 586 String::new()
586 587 };
587 588
588 - // Upload to S3 via multipart streaming from disk — the previous
589 + // Upload to S3 via multipart streaming from disk, the previous
589 590 // implementation `tokio::fs::read` → `Vec<u8>` → `upload_object` pinned
590 591 // the entire artifact (up to ~100 MB per build) in RAM during upload.
591 592 // `upload_multipart` reads the file in chunks and lets the S3 SDK do
@@ -605,22 +606,22 @@ async fn execute_target(
605 606 .await
606 607 .map_err(|e| format!("S3 multipart upload failed: {e}"));
607 608
608 - // Always remove the local temp file, even if the upload failed — leaving
609 + // Always remove the local temp file, even if the upload failed, leaving
609 610 // it on disk fills the build runner's tmp directory across retries.
610 611 let _ = tokio::fs::remove_file(&local_tmp).await;
611 612
612 613 upload_result?;
613 614
614 - if !signature.is_empty() {
615 + if signature.is_empty() {
616 + let _ =
617 + append_log_bounded(ctx, build.id, &format!("[{target}] uploaded to {s3_key}\n")).await;
618 + } else {
615 619 let _ = append_log_bounded(
616 620 ctx,
617 621 build.id,
618 622 &format!("[{target}] uploaded to {s3_key} (signed)\n"),
619 623 )
620 624 .await;
621 - } else {
622 - let _ =
623 - append_log_bounded(ctx, build.id, &format!("[{target}] uploaded to {s3_key}\n")).await;
624 625 }
625 626
626 627 Ok((s3_key.into_string(), signature))
@@ -672,8 +673,8 @@ async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<Strin
672 673 .stdout(std::process::Stdio::piped())
673 674 .stderr(std::process::Stdio::piped())
674 675 // Kill the ssh process if this future is dropped (e.g. the 30-min build
675 - // timeout fires): otherwise the dropped future leaves ssh — and the
676 - // remote build it drives — running orphaned (ultra-fuzz Run 11 Perf).
676 + // timeout fires): otherwise the dropped future leaves ssh, and the
677 + // remote build it drives, running orphaned (ultra-fuzz Run 11 Perf).
677 678 .kill_on_drop(true)
678 679 .spawn()
679 680 .map_err(|e| format!("failed to spawn ssh: {e}"))?;
@@ -828,9 +829,9 @@ fn strip_ansi_escapes(s: &str) -> String {
828 829 ///
829 830 /// The operator-configured `build_command` is a single string (e.g.
830 831 /// `RUSTFLAGS=--cfg cargo build --release`). Rather than interpolate it raw into
831 - /// the remote `sh -c` script — where its safety rested entirely on a
832 + /// the remote `sh -c` script, where its safety rested entirely on a
832 833 /// metacharacter denylist, one added allowed character away from reopening
833 - /// injection — it is tokenised into leading `NAME=VALUE` environment
834 + /// injection, it is tokenised into leading `NAME=VALUE` environment
834 835 /// assignments followed by a program and its arguments. `render` emits every
835 836 /// element individually shell-escaped, applying assignments via `env`, so no
836 837 /// operator byte can break out of its shell word. Shell injection is
@@ -934,8 +935,8 @@ fn validate_command_token(tok: &str) -> std::result::Result<(), String> {
934 935 }
935 936
936 937 /// Validate a build command for shell safety at config-write time. Validation is
937 - /// exactly "parses into a [`RemoteCommand`]" — the same parser the executor uses
938 - /// — so a stored command that validates here can never fail to render safely.
938 + /// exactly "parses into a [`RemoteCommand`]", the same parser the executor uses
939 + ///, so a stored command that validates here can never fail to render safely.
939 940 pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> {
940 941 RemoteCommand::parse(cmd).map(|_| ())
941 942 }
@@ -8,7 +8,7 @@
8 8 //!
9 9 //! Optional by design: when `CF_API_TOKEN` / `CF_ZONE_ID` are not configured,
10 10 //! [`CloudflarePurger::from_env`] returns `None` and every purge becomes a
11 - //! logged no-op — the malware-quarantine WAM ticket remains the manual
11 + //! logged no-op, the malware-quarantine WAM ticket remains the manual
12 12 //! fallback, exactly the posture before this wiring existed.
13 13
14 14 use crate::helpers::HTTP_CLIENT;
@@ -47,7 +47,7 @@ impl CloudflarePurger {
47 47 /// Purge specific absolute URLs from Cloudflare's edge cache.
48 48 ///
49 49 /// Fire-and-forget at the call site (callers should not block enforcement on
50 - /// it); failures are logged, not propagated — the object is already gone from
50 + /// it); failures are logged, not propagated, the object is already gone from
51 51 /// origin, so a failed edge purge degrades to the prior TTL-bounded residual
52 52 /// rather than a serving failure. Batches to Cloudflare's per-request cap.
53 53 pub async fn purge_urls(&self, urls: Vec<String>) {
@@ -54,13 +54,13 @@ pub struct Config {
54 54 pub integrations: IntegrationsConfig,
55 55 /// Site-wide access gate. `Open` (default) serves the public site as
56 56 /// normal. `FanPlusOrCreator` restricts the whole site to logged-in users
57 - /// with a creator account or an active Fan+ subscription — used on the
57 + /// with a creator account or an active Fan+ subscription, used on the
58 58 /// testnot.work staging mirror so it's reachable only by Fan+/creator
59 59 /// accounts. Off in production.
60 60 pub access_gate: AccessGate,
61 61 /// Upstream SSO provider for "Sign in with Makenot.work" (optional). When
62 62 /// set, the login page becomes a single button that authenticates against
63 - /// `provider_url`'s OAuth endpoints instead of a local password form — used
63 + /// `provider_url`'s OAuth endpoints instead of a local password form, used
64 64 /// on the testnot mirror so a password is only ever entered on production.
65 65 pub sso: Option<SsoConfig>,
66 66 }
@@ -148,7 +148,7 @@ pub struct SsoConfig {
148 148 pub client_id: String,
149 149 /// SyncKit SDK key string sent on token exchange. Any non-empty string the
150 150 /// provider's `validate_synckit_key` accepts; identifies no billing slot
151 - /// here — we discard the sync token and use only the returned `user_id`.
151 + /// here, we discard the sync token and use only the returned `user_id`.
152 152 pub key: String,
153 153 }
154 154
@@ -173,11 +173,11 @@ impl SsoConfig {
173 173 /// Site-wide access-gate mode (`ACCESS_GATE`).
174 174 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
175 175 pub enum AccessGate {
176 - /// No gate — the public site is served to everyone (production default).
176 + /// No gate, the public site is served to everyone (production default).
177 177 #[default]
178 178 Open,
179 179 /// Only logged-in creators or active Fan+ members may reach the site;
180 - /// everyone else is bounced to login. A coarse pre-filter — per-route auth
180 + /// everyone else is bounced to login. A coarse pre-filter, per-route auth
181 181 /// still applies underneath.
182 182 FanPlusOrCreator,
183 183 }
@@ -214,9 +214,9 @@ impl Config {
214 214 std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingDatabaseUrl)?;
215 215
216 216 let host_url =
217 - std::env::var("HOST_URL").unwrap_or_else(|_| format!("http://{}:{}", host, port));
217 + std::env::var("HOST_URL").unwrap_or_else(|_| format!("http://{host}:{port}"));
218 218
219 - // Secret key for signing tokens — required in production, random fallback in dev
219 + // Secret key for signing tokens, required in production, random fallback in dev
220 220 let signing_secret = match std::env::var("SIGNING_SECRET") {
221 221 Ok(secret) => {
222 222 if secret.len() < 32 {
@@ -227,9 +227,7 @@ impl Config {
227 227 Err(_) => {
228 228 // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start
229 229 let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
230 - || std::env::var("HOST_URL")
231 - .map(|u| u.starts_with("https://"))
232 - .unwrap_or(false);
230 + || std::env::var("HOST_URL").is_ok_and(|u| u.starts_with("https://"));
233 231 if is_production {
234 232 return Err(ConfigError::MissingSigningSecret);
235 233 }
@@ -378,15 +376,14 @@ impl Config {
378 376 // production env can't accidentally hand out founder pricing.
379 377 let creator_founder_window_open = std::env::var("CREATOR_FOUNDER_WINDOW_OPEN")
380 378 .ok()
381 - .map(|v| v == "true" || v == "1")
382 - .unwrap_or(false);
379 + .is_some_and(|v| v == "true" || v == "1");
383 380
384 381 // Build pipeline - optional, build trigger endpoint returns 503 if unset
385 382 let build_trigger_token = std::env::var("BUILD_TRIGGER_TOKEN").ok();
386 383 let build_host_linux = std::env::var("BUILD_HOST_LINUX").ok();
387 384 let build_host_darwin = std::env::var("BUILD_HOST_DARWIN").ok();
388 385
389 - // CDN base URL — REQUIRED in production, presigned-S3 fallback in dev.
386 + // CDN base URL, REQUIRED in production, presigned-S3 fallback in dev.
390 387 // Without a CDN, cover/download URLs are path-style presigned S3 URLs
391 388 // (`{endpoint}/{bucket}/{key}`), a shape the cover_s3_key backfill
392 389 // (migration 152) and other key-from-URL derivation do not expect. The
@@ -395,9 +392,7 @@ impl Config {
395 392 let cdn_base_url = std::env::var("CDN_BASE_URL").ok().filter(|s| !s.is_empty());
396 393 {
397 394 let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
398 - || std::env::var("HOST_URL")
399 - .map(|u| u.starts_with("https://"))
400 - .unwrap_or(false);
395 + || std::env::var("HOST_URL").is_ok_and(|u| u.starts_with("https://"));
401 396 if is_production && cdn_base_url.is_none() {
402 397 return Err(ConfigError::MissingCdnBaseUrl);
403 398 }
@@ -423,11 +418,10 @@ impl Config {
423 418 // missing/typo'd value can't silently reopen the spoofing hole.
424 419 let postmark_enforce_sender_auth = std::env::var("POSTMARK_ENFORCE_SENDER_AUTH")
425 420 .ok()
426 - .map(|v| !(v == "false" || v == "0"))
427 - .unwrap_or(true);
421 + .is_none_or(|v| !(v == "false" || v == "0"));
428 422
429 423 // Internal shared secret for MT communication. Bearer-token-equivalent, so
430 - // enforce the same >=32-char floor as the signing secrets — a short value
424 + // enforce the same >=32-char floor as the signing secrets, a short value
431 425 // is offline-brute-forceable. Fail closed rather than boot on a weak secret.
432 426 let internal_shared_secret = match std::env::var("INTERNAL_SHARED_SECRET") {
433 427 Ok(secret) => {
@@ -605,11 +599,11 @@ pub struct ScanConfig {
605 599 /// Minimum number of YARA rule files that must compile for the corpus to be
606 600 /// considered healthy. `0` disables the check. Defaults to
607 601 /// [`DEFAULT_YARA_MIN_RULE_FILES`] (the size of the bundled corpus) so a
608 - /// silent drop — a dependency/format change that makes rules uncompilable —
602 + /// silent drop, a dependency/format change that makes rules uncompilable,
609 603 /// fails boot loudly rather than degrading coverage unnoticed. Set it
610 604 /// explicitly when pointing `YARA_RULES_DIR` at a larger external corpus.
611 605 pub yara_min_rule_files: usize,
612 - /// The number of bytes ClamAV actually scans per object — the operator's
606 + /// The number of bytes ClamAV actually scans per object, the operator's
613 607 /// declared `min(MaxScanSize, MaxFileSize, StreamMaxLength)` from `clamd.conf`.
614 608 ///
615 609 /// clamd does NOT expose these limits over its socket (only `PING`/`VERSION`),
@@ -617,7 +611,7 @@ pub struct ScanConfig {
617 611 /// It gates whether ClamAV counts as a *full-file backstop* for the YARA
618 612 /// prefix cap ([`crate::constants::SCAN_YARA_MAX_BYTES`]): only a file whose
619 613 /// size is within this many bytes is treated as fully covered. `None` (the
620 - /// default) means "coverage unknown" and is fail-closed — any file above the
614 + /// default) means "coverage unknown" and is fail-closed, any file above the
621 615 /// YARA prefix is held for review rather than certified Clean on a
622 616 /// possibly-partial ClamAV scan (ultra-fuzz Run #24 Security MODERATE).
623 617 pub clamav_max_scan_bytes: Option<u64>,
@@ -634,9 +628,7 @@ impl ScanConfig {
634 628 /// Load scan configuration from environment variables.
635 629 /// Returns Some if SCAN_ENABLED=true (default), None if explicitly disabled.
636 630 pub fn from_env() -> Option<Self> {
637 - let enabled = std::env::var("SCAN_ENABLED")
638 - .map(|v| v != "false" && v != "0")
639 - .unwrap_or(true);
631 + let enabled = std::env::var("SCAN_ENABLED").map_or(true, |v| v != "false" && v != "0");
640 632
641 633 if !enabled {
642 634 return None;
@@ -647,11 +639,9 @@ impl ScanConfig {
647 639 yara_rules_dir: std::env::var("YARA_RULES_DIR")
648 640 .unwrap_or_else(|_| "yara-rules/".to_string()),
649 641 malwarebazaar_enabled: std::env::var("MALWAREBAZAAR_ENABLED")
650 - .map(|v| v != "false" && v != "0")
651 - .unwrap_or(true),
642 + .map_or(true, |v| v != "false" && v != "0"),
652 643 urlhaus_enabled: std::env::var("URLHAUS_ENABLED")
653 - .map(|v| v != "false" && v != "0")
654 - .unwrap_or(true),
644 + .map_or(true, |v| v != "false" && v != "0"),
655 645 abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()),
656 646 metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()),
657 647 yara_min_rule_files: match std::env::var("YARA_MIN_RULE_FILES") {
@@ -666,13 +656,13 @@ impl ScanConfig {
666 656 Err(_) => DEFAULT_YARA_MIN_RULE_FILES,
667 657 },
668 658 clamav_max_scan_bytes: match std::env::var("CLAMAV_MAX_SCAN_BYTES") {
669 - Ok(v) => v.parse::<u64>().map(Some).unwrap_or_else(|_| {
659 + Ok(v) => v.parse::<u64>().map_or_else(|_| {
670 660 tracing::warn!(
671 661 value = %v,
672 662 "CLAMAV_MAX_SCAN_BYTES is set but is not a valid number — treating ClamAV as no full-file backstop (large files held for review)"
673 663 );
674 664 None
675 - }),
665 + }, Some),
676 666 Err(_) => None,
677 667 },
678 668 })
@@ -695,7 +685,7 @@ impl std::fmt::Debug for ScanConfig {
695 685 &self.metadefender_api_key.as_ref().map(|_| "<set>"),
696 686 )
697 687 .field("clamav_max_scan_bytes", &self.clamav_max_scan_bytes)
698 - .finish()
688 + .finish_non_exhaustive()
699 689 }
700 690 }
701 691
@@ -707,13 +697,13 @@ pub struct StripeConfig {
707 697 /// Webhook signing secrets for v1 snapshot events (whsec_...).
708 698 ///
709 699 /// A list to accommodate multiple Stripe endpoints (e.g. `mnw-connect`
710 - /// for Connected-account events + `mnw-you` for platform events — Stripe
700 + /// for Connected-account events + `mnw-you` for platform events, Stripe
711 701 /// requires one endpoint per scope, and each endpoint has its own secret).
712 702 /// `verify_signature` accepts a match against any secret in the list.
713 703 /// Configured via `STRIPE_WEBHOOK_SECRET` as a comma-separated list.
714 704 pub webhook_secret: Vec<String>,
715 705 /// Webhook signing secret for v2 thin events (whsec_...)
716 - /// Optional — v2 endpoint returns 503 if not set.
706 + /// Optional, v2 endpoint returns 503 if not set.
717 707 pub webhook_secret_v2: Option<String>,
718 708 }
719 709
@@ -853,7 +843,7 @@ impl std::fmt::Debug for Config {
853 843 .field("wam_url", &self.integrations.wam_url)
854 844 .field("access_gate", &self.access_gate)
855 845 .field("sso", &self.sso.as_ref().map(|s| &s.provider_url))
856 - .finish()
846 + .finish_non_exhaustive()
857 847 }
858 848 }
859 849
@@ -998,7 +988,9 @@ mod tests {
998 988
999 989 impl EnvGuard {
1000 990 fn new() -> Self {
1001 - let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
991 + let lock = ENV_LOCK
992 + .lock()
993 + .unwrap_or_else(std::sync::PoisonError::into_inner);
1002 994 let snapshot = CONFIG_ENV_VARS
1003 995 .iter()
1004 996 .map(|&key| (key, std::env::var(key).ok()))
@@ -1010,7 +1002,7 @@ mod tests {
1010 1002 }
1011 1003
1012 1004 /// Remove all config env vars so from_env() sees a clean slate.
1013 - fn clear_all(&self) {
1005 + fn clear_all() {
1014 1006 for &key in CONFIG_ENV_VARS {
1015 1007 // SAFETY: test-only, serialized by mutex
1016 1008 unsafe {
@@ -1103,7 +1095,7 @@ mod tests {
1103 1095 #[test]
1104 1096 fn from_env_succeeds_with_required_vars() {
1105 1097 let guard = EnvGuard::new();
1106 - guard.clear_all();
1098 + EnvGuard::clear_all();
1107 1099
1108 1100 // SAFETY: test-only, serialized by EnvGuard mutex
1109 1101 unsafe {
@@ -1123,7 +1115,7 @@ mod tests {
1123 1115 #[test]
1124 1116 fn from_env_fails_without_database_url() {
1125 1117 let guard = EnvGuard::new();
1126 - guard.clear_all();
1118 + EnvGuard::clear_all();
1127 1119
1128 1120 let err = Config::from_env().unwrap_err();
1129 1121 assert!(
@@ -1136,7 +1128,7 @@ mod tests {
1136 1128 #[test]
1137 1129 fn from_env_fails_in_production_without_signing_secret() {
1138 1130 let guard = EnvGuard::new();
1139 - guard.clear_all();
1131 + EnvGuard::clear_all();
1140 1132
1141 1133 // SAFETY: test-only, serialized by EnvGuard mutex
1142 1134 unsafe {
@@ -1155,7 +1147,7 @@ mod tests {
1155 1147 #[test]
1156 1148 fn from_env_fails_in_production_without_cdn_base_url() {
1157 1149 let guard = EnvGuard::new();
1158 - guard.clear_all();
1150 + EnvGuard::clear_all();
1159 1151
1160 1152 // SAFETY: test-only, serialized by EnvGuard mutex
1161 1153 unsafe {
@@ -1176,7 +1168,7 @@ mod tests {
1176 1168 #[test]
1177 1169 fn from_env_accepts_production_with_cdn_base_url() {
1178 1170 let guard = EnvGuard::new();
1179 - guard.clear_all();
1171 + EnvGuard::clear_all();
1180 1172
1181 1173 // SAFETY: test-only, serialized by EnvGuard mutex
1182 1174 unsafe {
@@ -1197,7 +1189,7 @@ mod tests {
1197 1189 #[test]
1198 1190 fn from_env_fails_with_https_host_url_without_signing_secret() {
1199 1191 let guard = EnvGuard::new();
1200 - guard.clear_all();
1192 + EnvGuard::clear_all();
1201 1193
1202 1194 // SAFETY: test-only, serialized by EnvGuard mutex
1203 1195 unsafe {
@@ -1216,13 +1208,13 @@ mod tests {
1216 1208 #[test]
1217 1209 fn from_env_fails_with_short_synckit_jwt_secret() {
1218 1210 let guard = EnvGuard::new();
1219 - guard.clear_all();
1211 + EnvGuard::clear_all();
1220 1212
1221 1213 // SAFETY: test-only, serialized by EnvGuard mutex
1222 1214 unsafe {
1223 1215 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1224 1216 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
1225 - // 31 chars — one under the floor.
1217 + // 31 chars, one under the floor.
1226 1218 std::env::set_var("SYNCKIT_JWT_SECRET", "x".repeat(31));
1227 1219 }
1228 1220
@@ -1237,7 +1229,7 @@ mod tests {
1237 1229 #[test]
1238 1230 fn from_env_accepts_strong_synckit_jwt_secret() {
1239 1231 let guard = EnvGuard::new();
1240 - guard.clear_all();
1232 + EnvGuard::clear_all();
1241 1233
1242 1234 // SAFETY: test-only, serialized by EnvGuard mutex
1243 1235 unsafe {
@@ -1257,7 +1249,7 @@ mod tests {
1257 1249 #[test]
1258 1250 fn from_env_uses_random_dev_secret_when_not_production() {
1259 1251 let guard = EnvGuard::new();
1260 - guard.clear_all();
1252 + EnvGuard::clear_all();
1261 1253
1262 1254 // SAFETY: test-only, serialized by EnvGuard mutex
1263 1255 unsafe {
@@ -1284,12 +1276,12 @@ mod tests {
1284 1276 #[test]
1285 1277 fn from_env_storage_none_when_partially_set() {
1286 1278 let guard = EnvGuard::new();
1287 - guard.clear_all();
1279 + EnvGuard::clear_all();
1288 1280
1289 1281 // SAFETY: test-only, serialized by EnvGuard mutex
1290 1282 unsafe {
1291 1283 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1292 - // Set only some S3 vars — missing S3_SECRET_KEY and S3_ACCESS_KEY
1284 + // Set only some S3 vars, missing S3_SECRET_KEY and S3_ACCESS_KEY
1293 1285 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
1294 1286 std::env::set_var("S3_BUCKET", "test-bucket");
1295 1287 }
@@ -1305,7 +1297,7 @@ mod tests {
1305 1297 #[test]
1306 1298 fn from_env_storage_some_when_fully_set() {
1307 1299 let guard = EnvGuard::new();
1308 - guard.clear_all();
1300 + EnvGuard::clear_all();
1309 1301
1310 1302 // SAFETY: test-only, serialized by EnvGuard mutex
1311 1303 unsafe {
@@ -1329,7 +1321,7 @@ mod tests {
1329 1321 #[test]
1330 1322 fn from_env_stripe_none_when_secret_key_missing() {
1331 1323 let guard = EnvGuard::new();
1332 - guard.clear_all();
1324 + EnvGuard::clear_all();
1333 1325
1334 1326 // SAFETY: test-only, serialized by EnvGuard mutex
1335 1327 unsafe {
@@ -1349,7 +1341,7 @@ mod tests {
1349 1341 #[test]
1350 1342 fn from_env_stripe_none_when_webhook_secret_missing() {
1351 1343 let guard = EnvGuard::new();
1352 - guard.clear_all();
1344 + EnvGuard::clear_all();
1353 1345
1354 1346 // SAFETY: test-only, serialized by EnvGuard mutex
1355 1347 unsafe {
@@ -1369,7 +1361,7 @@ mod tests {
1369 1361 #[test]
1370 1362 fn from_env_stripe_some_when_fully_set() {
1371 1363 let guard = EnvGuard::new();
1372 - guard.clear_all();
1364 + EnvGuard::clear_all();
1373 1365
1374 1366 // SAFETY: test-only, serialized by EnvGuard mutex
1375 1367 unsafe {
@@ -1391,7 +1383,7 @@ mod tests {
1391 1383 #[test]
1392 1384 fn from_env_invalid_host_rejected() {
1393 1385 let guard = EnvGuard::new();
1394 - guard.clear_all();
1386 + EnvGuard::clear_all();
1395 1387
1396 1388 // SAFETY: test-only, serialized by EnvGuard mutex
1397 1389 unsafe {
@@ -1410,7 +1402,7 @@ mod tests {
1410 1402 #[test]
1411 1403 fn from_env_invalid_port_rejected() {
1412 1404 let guard = EnvGuard::new();
1413 - guard.clear_all();
1405 + EnvGuard::clear_all();
1414 1406
1415 1407 // SAFETY: test-only, serialized by EnvGuard mutex
1416 1408 unsafe {
@@ -1429,7 +1421,7 @@ mod tests {
1429 1421 #[test]
1430 1422 fn from_env_scan_disabled_when_explicitly_off() {
1431 1423 let guard = EnvGuard::new();
1432 - guard.clear_all();
1424 + EnvGuard::clear_all();
1433 1425
1434 1426 // SAFETY: test-only, serialized by EnvGuard mutex
1435 1427 unsafe {
@@ -1448,7 +1440,7 @@ mod tests {
1448 1440 #[test]
1449 1441 fn from_env_scan_enabled_by_default() {
1450 1442 let guard = EnvGuard::new();
1451 - guard.clear_all();
1443 + EnvGuard::clear_all();
1452 1444
1453 1445 // SAFETY: test-only, serialized by EnvGuard mutex
1454 1446 unsafe {
@@ -4,7 +4,7 @@
4 4 //! (file sizes, presign expiry, allowed types) remain in storage.rs since
5 5 //! they're only used there.
6 6
7 - // -- Database --
7 + // Database
8 8 pub const DB_POOL_MAX_CONNECTIONS: u32 = 25;
9 9 pub const DB_POOL_MIN_CONNECTIONS: u32 = 2;
10 10 pub const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3;
@@ -14,7 +14,7 @@ pub const DB_MAX_LIFETIME_SECS: u64 = 1800;
14 14 pub const DB_IDLE_TIMEOUT_SECS: u64 = 600;
15 15 /// Per-statement wall-clock ceiling, applied to every pooled connection via
16 16 /// `SET statement_timeout`. `acquire_timeout` only bounds *getting* a
17 - /// connection, not *running* a query — without this a single wedged query pins
17 + /// connection, not *running* a query, without this a single wedged query pins
18 18 /// its connection forever and 25 of them exhaust the pool with no recovery.
19 19 /// Generous so legitimate maintenance sweeps and boot migrations aren't killed;
20 20 /// a job that genuinely needs longer sets `SET LOCAL statement_timeout = 0` in
@@ -22,11 +22,11 @@ pub const DB_IDLE_TIMEOUT_SECS: u64 = 600;
22 22 pub const DB_STATEMENT_TIMEOUT_SECS: u64 = 120;
23 23 /// Ceiling on how long a statement waits to acquire a lock (`SET lock_timeout`).
24 24 /// A lock-contended query fails fast instead of blocking a connection
25 - /// indefinitely — the most common way the pool wedges.
25 + /// indefinitely, the most common way the pool wedges.
26 26 pub const DB_LOCK_TIMEOUT_SECS: u64 = 30;
27 27
28 28 /// Largest object a single presigned `PutObject` can carry. S3 / Ceph (Hetzner
29 - /// Object Storage) reject a single PUT above 5 GiB — larger objects require
29 + /// Object Storage) reject a single PUT above 5 GiB, larger objects require
30 30 /// multipart. The browser upload paths issue exactly one presigned PUT, so this
31 31 /// bounds them regardless of the (higher) per-tier `max_file_bytes`; files above
32 32 /// it upload through the CLI / desktop clients, which chunk them. Keeping the
@@ -37,8 +37,8 @@ pub const S3_SINGLE_PUT_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024;
37 37 /// Largest source a single server-side `CopyObject` can promote. S3 rejects a
38 38 /// one-shot copy above 5 GiB; a larger source must go through ranged multipart
39 39 /// `UploadPartCopy`. Numerically equal to [`S3_SINGLE_PUT_MAX_BYTES`] but a
40 - /// different limit — that one bounds a client PUT, this one bounds a server-side
41 - /// copy — so they are named separately and neither should be reused for the
40 + /// different limit, that one bounds a client PUT, this one bounds a server-side
41 + /// copy, so they are named separately and neither should be reused for the
42 42 /// other. Without this branch a large upload succeeds and then fails at promote,
43 43 /// the worst position to fail in.
44 44 pub const S3_SINGLE_COPY_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024;
@@ -47,21 +47,21 @@ pub const S3_SINGLE_COPY_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024;
47 47 /// forwarded by the CLI for the session. 24h comfortably exceeds any SSH session.
48 48 pub const INTERNAL_ACTOR_TTL_SECS: i64 = 86_400;
49 49
50 - // -- Sessions --
50 + // Sessions
51 51 pub const SESSION_EXPIRY_DAYS: i64 = 7;
52 52 /// Skip DB touch if validated within this window. Doubles as the upper bound on
53 - /// session-revocation lag (admin suspend, logout-everywhere, password change) —
53 + /// session-revocation lag (admin suspend, logout-everywhere, password change),
54 54 /// shorter = tighter revocation, slightly more DB load on the auth hot path.
55 55 pub const SESSION_TOUCH_CACHE_SECS: u64 = 5;
56 56 /// Cap on tracked sessions per user. Each login mints a `user_sessions` row;
57 57 /// without a bound, repeated logins (or an automated loop) grow the table
58 58 /// unboundedly. After a new session is recorded, the oldest beyond this many are
59 - /// pruned (a sliding window) — the current session is always the newest, so it
59 + /// pruned (a sliding window), the current session is always the newest, so it
60 60 /// is never evicted. Matches the 100-row session-listing cap, so anything pruned
61 61 /// was already invisible bloat, never a session a user could see or manage.
62 62 pub const MAX_SESSIONS_PER_USER: i64 = 100;
63 63
64 - // -- Login security --
64 + // Login security
65 65 pub const MAX_LOGIN_ATTEMPTS: i32 = 5;
66 66 pub const LOCKOUT_MINUTES: i64 = 15;
67 67 /// How long a half-completed login (password verified, awaiting 2FA) stays
@@ -69,21 +69,21 @@ pub const LOCKOUT_MINUTES: i64 = 15;
69 69 /// "unattended browser one TOTP from logged in" failure mode.
70 70 pub const PENDING_2FA_TTL_SECS: i64 = 600;
71 71
72 - // -- Email link expiry (seconds) --
72 + // Email link expiry (seconds)
73 73 pub const PASSWORD_RESET_EXPIRY_SECS: i64 = 900; // 15 minutes
74 74 pub const EMAIL_VERIFICATION_EXPIRY_SECS: i64 = 86400; // 24 hours
75 75 pub const ACCOUNT_DELETION_EXPIRY_SECS: i64 = 3600; // 1 hour
76 76
77 - // -- Stripe fees (for display only — actual fees set by Stripe) --
77 + // Stripe fees (for display only; actual fees set by Stripe)
78 78 pub const STRIPE_FEE_PERCENTAGE: f64 = 0.029; // 2.9%
79 79 pub const STRIPE_FEE_FIXED_CENTS: f64 = 30.0; // $0.30
80 80 /// Stripe minimum charge amount in cents (USD). Charges below this are rejected.
81 81 pub const STRIPE_MINIMUM_CHARGE_CENTS: i64 = 50; // $0.50
82 82
83 - // -- Page / query limits --
83 + // Page / query limits
84 84 pub const DASHBOARD_TRANSACTION_LIMIT: i64 = 100;
85 85
86 - // -- SyncKit --
86 + // SyncKit
87 87 pub const SYNCKIT_JWT_EXPIRY_SECS: i64 = 7 * 24 * 3600; // 7 days
88 88 pub const SYNCKIT_PUSH_MAX_CHANGES: usize = 500;
89 89 pub const SYNCKIT_PULL_PAGE_SIZE: i64 = 500;
@@ -109,10 +109,10 @@ pub const SYNCKIT_ROTATION_STALE_HOURS: i64 = 24;
109 109 pub const SYNCKIT_WARNINGS_PER_TICK: i64 = 200;
110 110 pub const SYNCKIT_ROTATION_BATCH_MAX: usize = 500;
111 111
112 - // -- Subscriptions --
112 + // Subscriptions
113 113 pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum
114 114
115 - // -- OAuth --
115 + // OAuth
116 116 pub const OAUTH_CODE_EXPIRY_SECS: i64 = 600; // 10 minutes
117 117 pub const OAUTH_CODE_LENGTH: usize = 32; // 32 bytes = 64 hex chars
118 118 /// Lifetime of an OAuth userinfo-scoped access token. Short by design: it is
@@ -125,29 +125,29 @@ pub const OAUTH_ACCESS_TOKEN_EXPIRY_SECS: i64 = 300; // 5 minutes
125 125 pub const OAUTH_REFRESH_TOKEN_EXPIRY_SECS: i64 = 30 * 24 * 3600; // 30 days
126 126 pub const OAUTH_REFRESH_TOKEN_LENGTH: usize = 32; // 32 bytes = 64 hex chars
127 127
128 - // -- Health monitoring --
128 + // Health monitoring
129 129 pub const HEALTH_CHECK_INTERVAL_SECS: u64 = 60;
130 130 pub const ALERT_COOLDOWN_SECS: u64 = 300; // 5 minutes
131 131 pub const HEALTH_HISTORY_RETAIN_DAYS: i64 = 90;
132 132
133 - // -- Scheduled publish --
133 + // Scheduled publish
134 134 pub const SCHEDULER_INTERVAL_SECS: u64 = 60;
135 135
136 136 // How often the rate-limiter bucket-map sweeper reclaims stale GCRA entries.
137 137 // Bounds limiter map size by active (not cumulative-unique) client keys.
138 138 pub const GOVERNOR_SWEEP_INTERVAL_SECS: u64 = 60;
139 139
140 - // -- TOTP / 2FA --
140 + // TOTP / 2FA
141 141 pub const TOTP_SKEW: u8 = 1; // Allow +/-1 time step (+/-30s)
142 142 pub const TOTP_STEP: u64 = 30; // 30-second windows
143 143 pub const TOTP_DIGITS: usize = 6; // 6-digit codes
144 144 pub const BACKUP_CODE_COUNT: usize = 10; // Generate 10 codes
145 145 pub const BACKUP_CODE_LENGTH: usize = 8; // 8 alphanumeric chars
146 146
147 - // -- Anti-enumeration --
147 + // Anti-enumeration
148 148 pub const USERNAME_CHECK_DELAY_MS: u64 = 400;
149 149
150 - // -- Rate limiting --
150 + // Rate limiting
151 151 // Auth endpoints (login, join): burst 5, then 2/sec.
152 152 // fast-tests: relaxed to burst 20 so lockout tests can fire 5+ attempts without hitting rate limiter.
153 153 #[cfg(not(feature = "fast-tests"))]
@@ -173,8 +173,8 @@ pub const API_EXPORT_RATE_LIMIT_BURST: u32 = 3;
173 173 // Guest checkout (public, no auth): burst 10, then 1/sec
174 174 pub const GUEST_CHECKOUT_RATE_LIMIT_PER_SEC: u64 = 1;
175 175 pub const GUEST_CHECKOUT_RATE_LIMIT_BURST: u32 = 10;
176 - // Guest download (public, no auth, token-gated): deliberately lenient — a buyer
177 - // may pull several files in a row — but still a per-IP ceiling so the endpoint
176 + // Guest download (public, no auth, token-gated): deliberately lenient, a buyer
177 + // may pull several files in a row, but still a per-IP ceiling so the endpoint
178 178 // can't be hammered anonymously. Burst 60, then 2/sec.
179 179 pub const GUEST_DOWNLOAD_RATE_LIMIT_PER_SEC: u64 = 2;
180 180 pub const GUEST_DOWNLOAD_RATE_LIMIT_BURST: u32 = 60;
@@ -192,10 +192,10 @@ pub const OAUTH_TOKEN_RATE_LIMIT_BURST: u32 = 10;
192 192 // SyncKit auth: burst 5, then 1/sec
193 193 pub const SYNCKIT_AUTH_RATE_LIMIT_PER_SEC: u64 = 1;
194 194 pub const SYNCKIT_AUTH_RATE_LIMIT_BURST: u32 = 5;
195 - // SyncKit sync (push/pull) — per-IP: burst 30, then 10/sec
195 + // SyncKit sync (push/pull), per-IP: burst 30, then 10/sec
196 196 pub const SYNCKIT_SYNC_RATE_LIMIT_MS: u64 = 100;
197 197 pub const SYNCKIT_SYNC_RATE_LIMIT_BURST: u32 = 30;
198 - // SyncKit sync — per-app: burst 60, then 20/sec (higher than per-IP because
198 + // SyncKit sync, per-app: burst 60, then 20/sec (higher than per-IP because
199 199 // a single app may have many users behind different IPs)
200 200 pub const SYNCKIT_APP_RATE_LIMIT_MS: u64 = 50;
201 201 pub const SYNCKIT_APP_RATE_LIMIT_BURST: u32 = 60;
@@ -207,19 +207,19 @@ pub const TWO_FACTOR_RATE_LIMIT_BURST: u32 = 5;
207 207 pub const DASHBOARD_READ_RATE_LIMIT_MS: u64 = 200;
208 208 pub const DASHBOARD_READ_RATE_LIMIT_BURST: u32 = 20;
209 209
210 - // -- Pagination --
210 + // Pagination
211 211 pub const DISCOVER_PAGE_SIZE: u32 = 25;
212 212 pub const FEED_PAGE_SIZE: u32 = 25;
213 213 pub const PAGINATION_WINDOW_SIZE: u32 = 5;
214 214
215 - // -- Creator broadcast fan-out --
215 + // Creator broadcast fan-out
216 216 /// Max concurrent in-flight email sends per broadcast. The outer worker
217 217 /// task spawns up to this many child tasks, then waits on one to drain
218 218 /// before spawning the next.
219 219 pub const BROADCAST_PARALLELISM: usize = 16;
220 220 /// Delay between successive broadcast send-task spawns. Spreads Postmark
221 - /// API load when a creator with thousands of followers fires a broadcast
222 - /// — at parallelism 16 + 100 ms cadence, steady-state is ~10 sends/sec.
221 + /// API load when a creator with thousands of followers fires a broadcast:
222 + /// at parallelism 16 + 100 ms cadence, steady-state is ~10 sends/sec.
223 223 pub const BROADCAST_CHUNK_DELAY_MS: u64 = 100;
224 224 /// Recipient cap per broadcast send. Above this, the request is refused
225 225 /// with an instruction to contact support. Bounds Postmark spend exposure
@@ -235,11 +235,11 @@ pub const BROADCAST_MAX_RECIPIENTS: usize = 10_000;
235 235 /// oldest-buyers slice the SQL chose.
236 236 pub const BUYER_DEPARTURE_MAX_NOTIFICATIONS: i64 = 50_000;
237 237
238 - // -- File scanning --
238 + // File scanning
239 239 pub const SCAN_MAX_MEMORY_BYTES: usize = 100 * 1024 * 1024; // 100 MB in-memory threshold
240 240 // Ceiling on in-flight scans, enforced by a semaphore around the CPU/clamd
241 241 // phase. NOTE: with `SCAN_WORKER_COUNT` workers each scanning one file at a
242 - // time, the real concurrency is `min(SCAN_MAX_CONCURRENT, SCAN_WORKER_COUNT)` —
242 + // time, the real concurrency is `min(SCAN_MAX_CONCURRENT, SCAN_WORKER_COUNT)`,
243 243 // today that's 2 (~200 MB peak), so this semaphore only begins to bind if the
244 244 // worker count is raised above it. Kept as an explicit ceiling so that raising
245 245 // `SCAN_WORKER_COUNT` can't silently blow past the memory budget. The
@@ -248,7 +248,7 @@ pub const SCAN_MAX_CONCURRENT: usize = 4; // Memory-budget ceiling on concurrent
248 248 pub const SCAN_WORKER_COUNT: usize = 2; // Background worker tasks draining scan_jobs queue
249 249 /// Wall-clock ceiling on the CPU-bound scan layers (content-type, structural,
250 250 /// archive decompress, yara, sha256) as a whole. The per-layer deadlines that
251 - /// existed — yara 30s, clamav/urlhaus their own — did not cover the archive
251 + /// existed, yara 30s, clamav/urlhaus their own, did not cover the archive
252 252 /// decompress walk, which was byte-bounded (`SCAN_ZIP_MAX_UNCOMPRESSED`) but not
253 253 /// time-bounded, so a slow-codec archive under the ratio caps could pin one of
254 254 /// the two scan workers for its full decompress wall-time (fuzz 2026-07-06 F1).
@@ -285,7 +285,7 @@ pub const SCAN_SPOOL_FREE_RESERVE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
285 285 /// scratch before the writer aborts.
286 286 pub const SCAN_SPOOL_SLACK_BYTES: u64 = 16 * 1024 * 1024; // 16 MiB
287 287 /// Maximum number of bytes fed to YARA in a single scan. yara-x's `Scanner`
288 - /// walks the whole slice, which demand-pages the entire mmap resident — so an
288 + /// walks the whole slice, which demand-pages the entire mmap resident, so an
289 289 /// 8 GiB object would otherwise pin 8 GiB of page cache per scan (×
290 290 /// `SCAN_MAX_CONCURRENT`). Malware signatures cluster near a file's start, and
291 291 /// ClamAV (streamed, uncapped) is the full-file backstop, so scanning a generous
@@ -296,10 +296,10 @@ pub const SCAN_YARA_MAX_BYTES: usize = 512 * 1024 * 1024; // 512 MiB
296 296 /// (MalwareBazaar, MetaDefender). They are FailOpen by design; bounding each
297 297 /// await keeps a slow or unreachable third party from holding a scan worker slot
298 298 /// indefinitely (ultra-fuzz Run 6 Performance). On timeout the layer reports
299 - /// Skip — the same shape as the disabled case — never blocking the file.
299 + /// Skip, the same shape as the disabled case, never blocking the file.
300 300 pub const SCAN_EXTERNAL_LOOKUP_TIMEOUT_SECS: u64 = 10;
301 301
302 - // -- Caddy on-demand TLS --
302 + // Caddy on-demand TLS
303 303 // Caps concurrent cache-miss DB lookups in `/api/domains/caddy-ask`. Cache hits
304 304 // are unbounded (DashMap). At capacity, the handler returns 503 so Caddy retries
305 305 // later instead of stampeding the DB pool or driving ACME issuance for garbage
@@ -320,11 +320,11 @@ pub const SCAN_MALWAREBAZAAR_TIMEOUT_SECS: u64 = 5;
320 320 pub const SCAN_HTTP_CONNECT_TIMEOUT_SECS: u64 = 5;
321 321 pub const SCAN_CLAMAV_TIMEOUT_SECS: u64 = 30;
322 322
323 - // -- Invite system --
323 + // Invite system
324 324 pub const INVITES_ENABLED: bool = true;
325 325 pub const INVITE_LIMIT_PER_CREATOR: i64 = 5; // max unredeemed codes per creator
326 326
327 - // -- Git source browser --
327 + // Git source browser
328 328 pub const GIT_MAX_FILE_SIZE_BYTES: usize = 1_024_000; // 1MB display limit
329 329 pub const GIT_COMMITS_PER_PAGE: usize = 30;
330 330 pub const GIT_DIFF_MAX_FILES: usize = 20; // Inline diff hunks for first N files
@@ -363,14 +363,14 @@ pub const GIT_SSH_OP_TIMEOUT_SECS: u64 = 900; // 15 min
363 363 // per-push `GIT_SSH_MAX_PACK_BYTES` it bounds total repo growth per account.
364 364 pub const GIT_USER_DISK_QUOTA_BYTES: u64 = 20 * 1024 * 1024 * 1024; // 20 GiB backstop
365 365
366 - // -- Webhook security --
366 + // Webhook security
367 367 pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes
368 368
369 - // -- Collections --
369 + // Collections
370 370 pub const MAX_COLLECTIONS_PER_USER: i64 = 50;
371 371 pub const MAX_ITEMS_PER_COLLECTION: i64 = 200;
372 372
373 - // -- OTA updates --
373 + // OTA updates
374 374 pub const OTA_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour
375 375 // OTA management: burst 10, then 2/sec (same as API write)
376 376 pub const OTA_WRITE_RATE_LIMIT_MS: u64 = 500;
@@ -379,7 +379,7 @@ pub const OTA_WRITE_RATE_LIMIT_BURST: u32 = 10;
379 379 pub const OTA_READ_RATE_LIMIT_MS: u64 = 100;
380 380 pub const OTA_READ_RATE_LIMIT_BURST: u32 = 30;
381 381
382 - // -- Build pipeline --
382 + // Build pipeline
383 383 pub const BUILD_TIMEOUT_SECS: u64 = 1800; // 30 min
384 384 pub const BUILD_MAX_LOG_BYTES: usize = 5_242_880; // 5 MB
385 385 pub const BUILD_HISTORY_LIMIT: i64 = 50;
@@ -397,30 +397,30 @@ pub const BUILD_ALLOWED_TARGETS: &[&str] = &[
397 397 "darwin/aarch64",
398 398 ];
399 399
400 - // -- Streaming --
400 + // Streaming
401 401 pub const STREAMING_CACHE_MAX_SECS: u64 = 86400; // 24 hours max presigned URL lifetime
402 402 /// Rate limit for stream/download URL requests: 1 per 3 seconds, burst of 10.
403 403 pub const STREAM_RATE_LIMIT_MS: u64 = 3000;
404 404 pub const STREAM_RATE_LIMIT_BURST: u32 = 10;
405 405
406 - // -- Date display formats --
406 + // Date display formats
407 407 pub const DATE_FMT_SHORT: &str = "%b %d"; // "Mar 25"
408 408 pub const DATE_FMT_FULL: &str = "%b %d, %Y"; // "Mar 25, 2026"
409 409 pub const DATE_FMT_ISO: &str = "%Y-%m-%d"; // "2026-03-25"
410 410 pub const DATE_FMT_DATETIME: &str = "%b %d, %Y %H:%M"; // "Mar 25, 2026 14:30"
411 411 pub const DATE_FMT_DATETIME_UTC: &str = "%b %d, %Y %H:%M UTC"; // "Mar 25, 2026 14:30 UTC"
412 412
413 - // -- Platform content --
413 + // Platform content
414 414 pub const CHANGELOG_PROJECT_SLUG: &str = "changelog";
415 415
416 - // -- String / buffer limits --
416 + // String / buffer limits
417 417 pub const USER_AGENT_MAX_LENGTH: usize = 512;
418 418 pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096;
419 419 pub const MAX_PRICE_CENTS: i32 = 1_000_000; // $10,000
420 - /// Minimum for a non-zero buy-once price — Stripe rejects charges under $0.50.
420 + /// Minimum for a non-zero buy-once price, Stripe rejects charges under $0.50.
421 421 pub const MIN_BUY_ONCE_PRICE_CENTS: i32 = 50; // $0.50
422 422
423 - // -- Sandbox accounts --
423 + // Sandbox accounts
424 424 /// How long a sandbox session lasts before auto-cleanup.
425 425 pub const SANDBOX_EXPIRY_SECS: i64 = 3600; // 1 hour
426 426 /// How often the cleanup job runs.
@@ -586,7 +586,7 @@ const _: () = assert!(SYNCKIT_MAX_KEY_ENVELOPE_BYTES > 0);
586 586 mod tests {
587 587 use super::*;
588 588
589 - /// The build-target FORMAT check uses `str::contains`, which isn't const —
589 + /// The build-target FORMAT check uses `str::contains`, which isn't const,
590 590 /// so this invariant stays a runtime test (the rest are compile-time above).
591 591 #[test]
592 592 fn build_allowed_targets_are_os_slash_arch() {
@@ -1,6 +1,8 @@
1 1 //! Cryptographic utilities: constant-time comparison, key generation, feed
2 2 //! signing, and secret encryption at rest.
3 3
4 + use std::fmt::Write as _;
5 +
4 6 use crate::error::{AppError, Result};
5 7
6 8 /// Version-tagged prefix on an encrypted-at-rest TOTP secret. Its presence is
@@ -28,7 +30,7 @@ fn totp_encryption_key(signing_secret: &str) -> [u8; 32] {
28 30 /// The on-disk form is `enc:v1:` + base64(`nonce(12) || ciphertext+tag`). A
29 31 /// fresh random nonce is drawn per call, so encrypting the same seed twice
30 32 /// yields distinct ciphertexts. A database read alone (snapshot, replica, SQL
31 - /// injection elsewhere) no longer yields a usable second factor — the attacker
33 + /// injection elsewhere) no longer yields a usable second factor; the attacker
32 34 /// also needs `SIGNING_SECRET`.
33 35 pub fn encrypt_totp_secret(plaintext: &str, signing_secret: &str) -> String {
34 36 use base64::Engine;
@@ -60,7 +62,7 @@ pub fn encrypt_totp_secret(plaintext: &str, signing_secret: &str) -> String {
60 62 /// Decrypt a stored TOTP secret produced by [`encrypt_totp_secret`].
61 63 ///
62 64 /// Every stored secret MUST carry the `enc:v1:` prefix. There is no legacy
63 - /// plaintext fallback — a value without the prefix is rejected as malformed
65 + /// plaintext fallback; a value without the prefix is rejected as malformed
64 66 /// (backwards compatibility with pre-encryption plaintext seeds was cut, so
65 67 /// any such user re-enrolls their authenticator). Also errors when a tagged
66 68 /// ciphertext fails to decode or authenticate (wrong key or tampering).
@@ -101,7 +103,7 @@ pub fn decrypt_totp_secret(stored: &str, signing_secret: &str) -> Result<String>
101 103 /// secrets. Backed by [`subtle::ConstantTimeEq`] (audited reference impl)
102 104 /// instead of a hand-rolled XOR loop wrapped in cosmetic SHA-256.
103 105 ///
104 - /// Length mismatch short-circuits — leaking the length of fixed-format
106 + /// Length mismatch short-circuits; leaking the length of fixed-format
105 107 /// tokens (hex-encoded HMACs, CSRF tokens, PKCE verifiers, base64 secrets)
106 108 /// reveals nothing useful to an attacker, since the format already fixes
107 109 /// the length. Don't use this for variable-length sensitive payloads
@@ -121,8 +123,8 @@ pub fn constant_time_compare(a: &str, b: &str) -> bool {
121 123 /// Six random words from the 2048-word list (~66 bits of entropy). Six was
122 124 /// chosen over five (~55 bits) after a birthday-collision review: at five
123 125 /// words, ~190M keys gives a coin-flip chance of collision; at six, the
124 - /// equivalent threshold rises to ~6B keys — far past the lifetime cap of
125 - /// any realistic license catalog. Returns a `KeyCode` via `from_trusted` —
126 + /// equivalent threshold rises to ~6B keys, far past the lifetime cap of
127 + /// any realistic license catalog. Returns a `KeyCode` via `from_trusted`;
126 128 /// the wordlist guarantees validity.
127 129 pub fn generate_key_code() -> crate::db::KeyCode {
128 130 use rand::RngExt;
@@ -143,7 +145,10 @@ pub fn generate_key_code() -> crate::db::KeyCode {
143 145 pub fn generate_git_token() -> (String, String) {
144 146 let mut bytes = [0u8; 32];
145 147 rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes);
146 - let body: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
148 + let body: String = bytes.iter().fold(String::new(), |mut out, b| {
149 + let _ = write!(out, "{b:02x}");
150 + out
151 + });
147 152 let plaintext = format!("mnw_{body}");
148 153 let hash = git_token_hash(&plaintext);
149 154 (plaintext, hash)
@@ -156,8 +161,10 @@ pub fn sha256_hex(input: &str) -> String {
156 161 use sha2::{Digest, Sha256};
157 162 Sha256::digest(input.as_bytes())
158 163 .iter()
159 - .map(|b| format!("{b:02x}"))
160 - .collect()
164 + .fold(String::new(), |mut out, b| {
165 + let _ = write!(out, "{b:02x}");
166 + out
167 + })
161 168 }
162 169
163 170 /// SHA-256 hex digest of a git token's plaintext. Used both when minting a
@@ -185,7 +192,7 @@ const INTERNAL_ACTOR_DOMAIN: &str = "internal-actor:v1";
185 192 /// by SSH key) and the CLI forwards it on internal calls. Because the key is the
186 193 /// server-only `signing_secret` (never held by the CLI or embedded in the shared
187 194 /// `cli_service_token`), a leaked service token alone cannot forge an assertion
188 - /// for another user — so it cannot act as an arbitrary user.
195 + /// for another user, so it cannot act as an arbitrary user.
189 196 pub fn mint_internal_actor_token(
190 197 user_id: crate::db::UserId,
191 198 expiry_unix: i64,
@@ -255,13 +262,13 @@ pub fn generate_feed_url(
255 262 secret: &str,
256 263 ) -> String {
257 264 let sig = feed_signature(user_id, version, secret);
258 - format!("{}/feed/{}?v={}&sig={}", host_url, user_id, version, sig)
265 + format!("{host_url}/feed/{user_id}?v={version}&sig={sig}")
259 266 }
260 267
261 268 /// Verify a personal feed URL signature for a given `(user_id, version)`.
262 269 ///
263 270 /// The caller MUST additionally check that `version` equals the user's current
264 - /// `feed_key_version` — a valid signature for a stale version is a revoked URL.
271 + /// `feed_key_version`; a valid signature for a stale version is a revoked URL.
265 272 pub fn verify_feed_signature(
266 273 user_id: crate::db::UserId,
267 274 version: i32,
@@ -373,7 +380,7 @@ mod tests {
373 380 fn totp_secret_tampered_ciphertext_fails() {
374 381 let key = "a-stable-signing-secret-at-least-32c";
375 382 let enc = encrypt_totp_secret("JBSWY3DPEHPK3PXP", key);
376 - // Flip a character in the base64 body — the AEAD tag must reject it.
383 + // Flip a character in the base64 body; the AEAD tag must reject it.
377 384 let mut bytes: Vec<char> = enc.chars().collect();
378 385 let last = bytes.len() - 1;
379 386 bytes[last] = if bytes[last] == 'A' { 'B' } else { 'A' };
@@ -429,18 +436,15 @@ mod tests {
429 436 for word in &parts {
430 437 assert!(
431 438 word.len() >= 3,
432 - "Each word should be at least 3 chars: {}",
433 - word
439 + "Each word should be at least 3 chars: {word}"
434 440 );
435 441 assert!(
436 442 word.len() <= 6,
437 - "Each word should be at most 6 chars: {}",
438 - word
443 + "Each word should be at most 6 chars: {word}"
439 444 );
440 445 assert!(
441 446 word.chars().all(|c| c.is_ascii_lowercase()),
442 - "Words should be lowercase: {}",
443 - word
447 + "Words should be lowercase: {word}"
444 448 );
445 449 }
446 450 }
@@ -495,7 +499,7 @@ mod tests {
495 499
496 500 #[test]
497 501 fn feed_url_stale_version_rejected() {
498 - // A signature minted for version 0 must not verify against version 1 —
502 + // A signature minted for version 0 must not verify against version 1;
499 503 // this is what makes "Regenerate feed URL" revoke the old link.
500 504 let user_id = crate::db::UserId::new();
501 505 let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
M server/src/csrf.rs +46 -45
@@ -31,7 +31,7 @@ use crate::error::{AppError, ResultExt};
31 31 // invariant in one assertion instead of relying on per-route tests or an audit
32 32 // to notice a route that drifted. Populated after `build_app` has run once.
33 33
34 - /// Posture kind recorded in the manifest — reason-free and `'static`-free so it
34 + /// Posture kind recorded in the manifest, reason-free and `'static`-free so it
35 35 /// can live in a process-global. Derived from [`CsrfPosture`].
36 36 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
37 37 pub enum ManifestPosture {
@@ -51,7 +51,7 @@ pub struct CsrfRouteEntry {
51 51
52 52 /// Keyed by path: a path has exactly one posture (multi-method routes share it,
53 53 /// and Axum forbids registering a path twice). Re-registration across
54 - /// `build_app` rebuilds (every test harness builds a fresh app) is idempotent —
54 + /// `build_app` rebuilds (every test harness builds a fresh app) is idempotent,
55 55 /// the same key overwrites with identical data.
56 56 static CSRF_MANIFEST: LazyLock<Mutex<BTreeMap<String, CsrfRouteEntry>>> =
57 57 LazyLock::new(|| Mutex::new(BTreeMap::new()));
@@ -96,7 +96,7 @@ pub fn generate_token() -> String {
96 96 ///
97 97 /// `tower-sessions`' `insert` is last-write-wins, so two concurrent first
98 98 /// requests (e.g. the user opens two tabs while not yet having a token)
99 - /// can each generate a fresh token and clobber each other at store-save time —
99 + /// can each generate a fresh token and clobber each other at store-save time,
100 100 /// the losing tab's rendered token is then stale and its first mutation gets a
101 101 /// 403, after which a reload picks up the winning token. This is a rare,
102 102 /// self-correcting edge for the brief window before a session has any token; it
@@ -118,7 +118,7 @@ pub async fn get_or_create_token(session: &Session) -> Result<String, AppError>
118 118 .context("session insert")?;
119 119
120 120 // Return the token we just inserted. (A prior version re-read the key here,
121 - // claiming to reconcile a concurrent insert — but two concurrent
121 + // claiming to reconcile a concurrent insert, but two concurrent
122 122 // first-requests sharing a cookie each hold an independent session load, so
123 123 // the re-read only ever observes THIS request's own insert and returned
124 124 // `candidate` unconditionally. The store reconciles the tabs last-writer-wins
@@ -149,7 +149,7 @@ pub fn extract_token_from_request(headers: &HeaderMap, body: Option<&str>) -> Op
149 149 if let Some(token) = headers
150 150 .get("X-CSRF-Token")
151 151 .and_then(|v| v.to_str().ok())
152 - .map(|s| s.to_string())
152 + .map(std::string::ToString::to_string)
153 153 {
154 154 return Some(token);
155 155 }
@@ -157,7 +157,7 @@ pub fn extract_token_from_request(headers: &HeaderMap, body: Option<&str>) -> Op
157 157 // Fall back to the `_csrf` field in form-encoded body (vanilla HTML
158 158 // forms). We use a proper urlencoded parser instead of `split('&')`
159 159 // so a textarea containing `&_csrf=attacker-token` can't sneak past
160 - // a later field with the wrong value — the parser respects field
160 + // a later field with the wrong value, the parser respects field
161 161 // ordering and won't conflate textarea content with form fields
162 162 // because the form encoder percent-encodes `&` inside text values.
163 163 if let Some(body_str) = body {
@@ -214,7 +214,7 @@ pub enum CsrfPosture {
214 214 /// Witness type proving a handler ran the standard CSRF validation path.
215 215 /// The only public way to obtain one is `validate_token_consuming`, which
216 216 /// performs the check. The private field with a private-module constructor
217 - /// makes the value un-fabricable from outside this module — `Default`,
217 + /// makes the value un-fabricable from outside this module, `Default`,
218 218 /// struct-literal, and `Clone` are all impossible for callers.
219 219 pub use sealed::CsrfManuallyValidated;
220 220
@@ -230,7 +230,7 @@ mod sealed {
230 230
231 231 /// Validate a token and return a sealed witness on success. Used by
232 232 /// handlers registered with `post_csrf_manual` (and method variants)
233 - /// that need to validate inside the handler body — typically because the
233 + /// that need to validate inside the handler body, typically because the
234 234 /// global middleware can't read the token for this content type (e.g.
235 235 /// multipart) or because validation is conditional on request state.
236 236 pub async fn validate_token_consuming(
@@ -246,10 +246,10 @@ pub async fn validate_token_consuming(
246 246
247 247 // Manual-posture runtime assertion (dev/test only): attempted via a tokio
248 248 // task-local flag set in `validate_token_consuming` and checked in a per-
249 - // route layer. Backed out 2026-05-27 — false-positive density was too high:
249 + // route layer. Backed out 2026-05-27, false-positive density was too high:
250 250 // rendered error pages return 200, rate-limit and form-extraction
251 251 // short-circuit before the handler, and the audit explicitly marked this
252 - // follow-up as "not blocking — only matters if Manual grows beyond one
252 + // follow-up as "not blocking, only matters if Manual grows beyond one
253 253 // route". Compile-time discipline (the `CsrfManuallyValidated` witness type
254 254 // bound as `_validated`) stays the convention.
255 255
@@ -267,12 +267,12 @@ where
267 267
268 268 /// A `MethodRouter` that has been through one of the CSRF helpers. Field
269 269 /// is private and constructible only inside this module, so
270 - /// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)`
271 - /// — route files have to use the helpers, by construction.
270 + /// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)`;
271 + /// route files have to use the helpers, by construction.
272 272 pub use posture_router::PostureMethodRouter;
273 273
274 274 mod posture_router {
275 - use super::*;
275 + use super::{CsrfPosture, MethodRouter};
276 276
277 277 pub struct PostureMethodRouter<S = ()> {
278 278 inner: MethodRouter<S>,
@@ -298,6 +298,7 @@ mod posture_router {
298 298 /// Attach an additional tower layer (e.g. a rate limiter) to the
299 299 /// underlying method router. Returns `Self` so callers don't lose
300 300 /// the posture stamp.
301 + #[must_use]
301 302 pub fn layer<L>(self, layer: L) -> Self
302 303 where
303 304 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
@@ -372,7 +373,7 @@ csrf_passthrough_helper!(delete_csrf_skip, delete, Skip);
372 373 // (e.g. `get(list).post(create)`). The handler-taking helpers above can't
373 374 // compose with these because the chain is already a `MethodRouter`. These
374 375 // wrappers take a pre-built `MethodRouter` and stamp it as a
375 - // `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected — the
376 + // `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected, the
376 377 // Auto validation layer only intercepts state-changing methods at the
377 378 // per-route level because that's what the helper attached to.
378 379
@@ -384,7 +385,7 @@ where
384 385 posture_router::PostureMethodRouter::new(attach_auto_layer(method_router), CsrfPosture::Auto)
385 386 }
386 387
387 - /// Stamp a multi-method chain as Manual — handler is responsible for
388 + /// Stamp a multi-method chain as Manual, handler is responsible for
388 389 /// calling `validate_token_consuming`.
389 390 pub fn with_csrf_manual<S>(
390 391 reason: &'static str,
@@ -396,7 +397,7 @@ where
396 397 posture_router::PostureMethodRouter::new(method_router, CsrfPosture::Manual(reason))
397 398 }
398 399
399 - /// Stamp a multi-method chain as Skip — no CSRF check applies.
400 + /// Stamp a multi-method chain as Skip, no CSRF check applies.
400 401 pub fn with_csrf_skip<S>(
401 402 reason: &'static str,
402 403 method_router: MethodRouter<S>,
@@ -410,7 +411,7 @@ where
410 411 // --- Origin gate: posture-independent pre-auth seal ----------------------
411 412 //
412 413 // Applied once to the whole `CsrfRouter` tree in `finalize`, so it covers
413 - // every registered route — Auto, Manual, and Skip alike — and runs before any
414 + // every registered route, Auto, Manual, and Skip alike, and runs before any
414 415 // per-route posture. It closes the pre-auth forgery vector (CHRONIC A'): the
415 416 // per-route `validate_auto` deliberately does NOT require a token from a
416 417 // logged-out caller (the public form path relies on the anonymous-session
@@ -418,7 +419,7 @@ where
418 419 // as `/forgot-password` would reach the handler.
419 420 //
420 421 // Policy: reject only when the request is *positively* identified as
421 - // cross-site. A request carrying no origin signal at all is allowed through —
422 + // cross-site. A request carrying no origin signal at all is allowed through,
422 423 // every modern browser sends `Sec-Fetch-Site`, so the no-signal case is
423 424 // non-browser traffic (Stripe webhooks, OAuth callbacks, mnw-cli, curl) that
424 425 // cannot be driven cross-site from a victim's browser. This keeps server-to-
@@ -436,7 +437,7 @@ fn is_mutating(method: &axum::http::Method) -> bool {
436 437
437 438 /// Host (no scheme, no port, lowercased) from an `Origin`/`Referer` value.
438 439 /// `None` for opaque origins (`"null"`), scheme-less values, or anything
439 - /// unparseable — callers treat `None` as "no usable signal" (allow).
440 + /// unparseable, callers treat `None` as "no usable signal" (allow).
440 441 fn url_host(value: &str) -> Option<String> {
441 442 let rest = value
442 443 .strip_prefix("https://")
@@ -532,6 +533,7 @@ where
532 533 Self(Router::new())
533 534 }
534 535
536 + #[must_use]
535 537 pub fn route(self, path: &str, posture: PostureMethodRouter<S>) -> Self {
536 538 record_route(path, posture.posture());
537 539 Self(self.0.route(path, posture.into_inner()))
@@ -541,20 +543,24 @@ where
541 543 /// guarantee only constrains state-changing methods, so read-only
542 544 /// `MethodRouter`s pass through unchanged. Calling this with a
543 545 /// `MethodRouter` that includes POST/PUT/PATCH/DELETE compiles, but
544 - /// readers can see the intent at the call site — and any mutation
546 + /// readers can see the intent at the call site, and any mutation
545 547 /// route registered through `route_get` is a bug visible in review.
548 + #[must_use]
546 549 pub fn route_get(self, path: &str, method_router: MethodRouter<S>) -> Self {
547 550 Self(self.0.route(path, method_router))
548 551 }
549 552
553 + #[must_use]
550 554 pub fn merge(self, other: Self) -> Self {
551 555 Self(self.0.merge(other.0))
552 556 }
553 557
558 + #[must_use]
554 559 pub fn nest(self, path: &str, other: Self) -> Self {
555 560 Self(self.0.nest(path, other.0))
556 561 }
557 562
563 + #[must_use]
558 564 pub fn layer<L>(self, layer: L) -> Self
559 565 where
560 566 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
@@ -567,6 +573,7 @@ where
567 573 Self(self.0.layer(layer))
568 574 }
569 575
576 + #[must_use]
570 577 pub fn route_layer<L>(self, layer: L) -> Self
571 578 where
572 579 L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
@@ -593,8 +600,8 @@ where
593 600 /// `CsrfRouter` tree (git smart-HTTP, SSO, embed) are not wrapped by this
594 601 /// `origin_gate`. That is sound because those surfaces are GET-only or
595 602 /// authenticated by a PAT/bearer rather than a session cookie (so they are
596 - /// legitimately CSRF-exempt). The one mutating route among them — git
597 - /// `receive-pack` (push) — ENFORCES this: `authorize_push` requires a
603 + /// legitimately CSRF-exempt). The one mutating route among them, git
604 + /// `receive-pack` (push), ENFORCES this: `authorize_push` requires a
598 605 /// push-scoped PAT (`token_push == Some(true)`) and rejects session-cookie
599 606 /// auth, so a cross-origin cookie POST cannot drive a write (UX-S1, Run 7).
600 607 /// Any future cookie-authed POST added to one of these surfaces must route
@@ -608,7 +615,7 @@ where
608 615 /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes
609 616 /// and by the path-allowlist fallback during the L2 migration.
610 617 async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
611 - // Safe methods (RFC 9110 §9.2.1) are read-only by definition — never
618 + // Safe methods (RFC 9110 §9.2.1) are read-only by definition, never
612 619 // CSRF-check them. This matters for multi-method routes wrapped by
613 620 // `with_csrf(get(load).post(save))`: a bare GET should not require a
614 621 // token (and the harness doesn't send one for GETs).
@@ -636,7 +643,7 @@ async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
636 643 .headers()
637 644 .get("X-CSRF-Token")
638 645 .and_then(|v| v.to_str().ok())
639 - .map(|s| s.to_string());
646 + .map(std::string::ToString::to_string);
640 647
641 648 if let Some(ref token) = header_token {
642 649 return match validate_token(&session, token).await {
@@ -653,14 +660,14 @@ async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
653 660 };
654 661 }
655 662
656 - // No header token — fall through to the form-body `_csrf` check.
663 + // No header token, fall through to the form-body `_csrf` check.
657 664 //
658 665 // CHRONIC A' (2026-06-15): this previously skipped validation entirely for
659 - // logged-out callers (`if !has_user { return next.run … }`), which let a
666 + // logged-out callers (`if !has_user { return next.run ... }`), which let a
660 667 // cross-site forged POST to a public form (e.g. `/forgot-password`) reach
661 668 // the handler. The skip is gone: every mutating request now requires a
662 669 // valid token regardless of auth state. Legitimate logged-out forms carry
663 - // one — `get_or_create_token` stamps the anonymous session on the GET
670 + // one, `get_or_create_token` stamps the anonymous session on the GET
664 671 // render and the template embeds `_csrf`. The posture-independent
665 672 // `origin_gate` (see `finalize`) is the complementary seal for browser
666 673 // forgery; this token check additionally covers header-less forged clients
@@ -678,7 +685,7 @@ async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
678 685 // the handler stream the body through a multipart parser before
679 686 // calling `validate_token_consuming`.
680 687 // - `application/json` and others must use the `X-CSRF-Token`
681 - // header — anything that can set a custom header can set this one.
688 + // header, anything that can set a custom header can set this one.
682 689 let content_type = request
683 690 .headers()
684 691 .get("content-type")
@@ -701,22 +708,16 @@ async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
701 708 // Limit matches the global RequestBodyLimitLayer (1 MB) so that any
702 709 // form body accepted by the server can have its CSRF token extracted.
703 710 let (parts, body) = request.into_parts();
704 - let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
705 - Ok(b) => b,
706 - Err(_) => {
707 - return (StatusCode::BAD_REQUEST, "Request body too large").into_response();
708 - }
711 + let Ok(bytes) = axum::body::to_bytes(body, 1024 * 1024).await else {
712 + return (StatusCode::BAD_REQUEST, "Request body too large").into_response();
709 713 };
710 714
711 715 let body_str = String::from_utf8_lossy(&bytes);
712 716 let body_token = extract_token_from_request(&HeaderMap::new(), Some(&body_str));
713 717
714 - let token = match body_token {
715 - Some(t) => t,
716 - None => {
717 - tracing::warn!(path = %path, "CSRF token missing from form body");
718 - return crate::error::AppError::Forbidden.into_response();
719 - }
718 + let Some(token) = body_token else {
719 + tracing::warn!(path = %path, "CSRF token missing from form body");
720 + return crate::error::AppError::Forbidden.into_response();
720 721 };
721 722
722 723 match validate_token(&session, &token).await {
@@ -740,8 +741,8 @@ async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
740 741 /// [`CsrfRouter`] tree (registered with a raw `post(...)` rather than
741 742 /// `post_csrf(...)`), and so are NOT protected by cookie-CSRF.
742 743 ///
743 - /// Each is safe only because it authenticates with a NON-cookie credential — a
744 - /// git Personal Access Token / bearer over the git smart-HTTP wire protocol —
744 + /// Each is safe only because it authenticates with a NON-cookie credential, a
745 + /// git Personal Access Token / bearer over the git smart-HTTP wire protocol,
745 746 /// so a browser can't be tricked into driving it cross-site. Adding a
746 747 /// cookie-authed mutation to a raw-merged surface (`page_routes`, `git_routes`,
747 748 /// `sso_routes`, `embed_routes` in `lib.rs`) is a CSRF hole: route it through
@@ -1028,7 +1029,7 @@ mod tests {
1028 1029
1029 1030 /// True if `verb(` occurs in `line` as a free-function call (axum's
1030 1031 /// `post`/`put`/`patch`/`delete` method routers) rather than a method call
1031 - /// (`.post(` on a reqwest client) or a CSRF helper (`post_csrf(` — the `(`
1032 + /// (`.post(` on a reqwest client) or a CSRF helper (`post_csrf(`, the `(`
1032 1033 /// must immediately follow the verb, which excludes `post_csrf`).
1033 1034 fn has_raw_method_router(line: &str, verb: &str) -> bool {
1034 1035 let needle = format!("{verb}(");
@@ -1037,7 +1038,7 @@ mod tests {
1037 1038 let at = from + rel;
1038 1039 let prev = line[..at].chars().next_back();
1039 1040 // Reject `.post(` (method call) and `xpost(` (identifier suffix like
1040 - // `post_csrf` can't reach here — `(` follows the verb directly).
1041 + // `post_csrf` can't reach here, `(` follows the verb directly).
1041 1042 if !matches!(prev, Some(c) if c == '.' || c.is_alphanumeric() || c == '_') {
1042 1043 return true;
1043 1044 }
@@ -1049,8 +1050,8 @@ mod tests {
1049 1050 /// Build-time seal (the D2 constructive fix): the router surfaces merged raw
1050 1051 /// in `lib.rs` outside the finalized `CsrfRouter` must not register any
1051 1052 /// mutating route that isn't a declared, justified [`CSRF_CARVE_OUTS`] entry.
1052 - /// A future cookie-authed `post(...)` added to a carve-out surface — instead
1053 - /// of `post_csrf(...)` — fails here rather than shipping a silent CSRF hole.
1053 + /// A future cookie-authed `post(...)` added to a carve-out surface, instead
1054 + /// of `post_csrf(...)`, fails here rather than shipping a silent CSRF hole.
1054 1055 #[test]
1055 1056 fn every_raw_mutation_outside_csrf_router_is_justified() {
1056 1057 use std::path::Path;
@@ -87,23 +87,20 @@ fn scope_and_sanitize(
87 87 );
88 88 }
89 89
90 - let mut stylesheet = match StyleSheet::parse(input, parser_options()) {
91 - Ok(s) => s,
92 - Err(_) => {
93 - tracing::warn!(
94 - input_len = input.len(),
95 - "custom-page CSS rejected: unparseable"
96 - );
97 - return (
98 - String::new(),
99 - vec![Rejection {
100 - kind: RejectionKind::MalformedCss,
101 - location: "css".into(),
102 - original_value: String::new(),
103 - reason: "CSS could not be parsed".into(),
104 - }],
105 - );
106 - }
90 + let Ok(mut stylesheet) = StyleSheet::parse(input, parser_options()) else {
91 + tracing::warn!(
92 + input_len = input.len(),
93 + "custom-page CSS rejected: unparseable"
94 + );
95 + return (
96 + String::new(),
97 + vec![Rejection {
98 + kind: RejectionKind::MalformedCss,
99 + location: "css".into(),
100 + original_value: String::new(),
101 + reason: "CSS could not be parsed".into(),
102 + }],
103 + );
107 104 };
108 105
109 106 let mut sanitizer = CssSanitizer {
@@ -194,8 +191,7 @@ fn scope_and_sanitize(
194 191
195 192 // Reduced-motion override, always last (decision #3). Scoped to the canvas.
196 193 let reduced_motion = format!(
197 - "@media (prefers-reduced-motion: reduce){{{sel},{sel} *{{animation:none!important;transition:none!important}}}}",
198 - sel = scope_selector
194 + "@media (prefers-reduced-motion: reduce){{{scope_selector},{scope_selector} *{{animation:none!important;transition:none!important}}}}"
199 195 );
200 196
201 197 let mut out = String::new();
@@ -265,7 +261,7 @@ struct CssSanitizer<'p> {
265 261 selector_count: usize,
266 262 }
267 263
268 - impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> {
264 + impl<'i> Visitor<'i> for CssSanitizer<'_> {
269 265 type Error = Infallible;
270 266
271 267 fn visit_types(&self) -> VisitTypes {
@@ -296,7 +292,7 @@ impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> {
296 292 // Explicitly allowed: plain style rules and the safe grouping/at-rules.
297 293 // Enumerated with NO wildcard arm (UX-S2, Run 7) so a future
298 294 // lightningcss upgrade that adds a `CssRule` variant fails to COMPILE
299 - // here until it is triaged into allow-or-block — instead of the old
295 + // here until it is triaged into allow-or-block, instead of the old
300 296 // `_ => None` silently emitting an unknown at-rule unfiltered into the
301 297 // `<style>` block.
302 298 CssRule::Media(_)
@@ -418,7 +414,7 @@ fn strip_hiding_properties(decls: &mut DeclarationBlock, rejections: &mut Vec<Re
418 414 fn is_hiding_property(prop: &Property) -> bool {
419 415 let norm = normalize(&prop_string(prop));
420 416 if let Some(rest) = norm.strip_prefix("opacity:") {
421 - return rest.parse::<f32>().map(|v| v < 0.1).unwrap_or(false);
417 + return rest.parse::<f32>().is_ok_and(|v| v < 0.1);
422 418 }
423 419 matches!(
424 420 norm.as_str(),
@@ -448,14 +444,13 @@ fn is_hiding_property(prop: &Property) -> bool {
448 444 || is_offscreen_text_indent(&norm)
449 445 }
450 446
451 - /// Large-negative `text-indent` — the classic off-screen text-hiding trick
447 + /// Large-negative `text-indent`, the classic off-screen text-hiding trick
452 448 /// (`text-indent:-9999px`). Anything ≤ -1000px (or unitless) counts as hiding.
453 449 fn is_offscreen_text_indent(norm: &str) -> bool {
454 450 norm.strip_prefix("text-indent:")
455 451 .map(|rest| rest.strip_suffix("px").unwrap_or(rest))
456 452 .and_then(|n| n.parse::<f32>().ok())
457 - .map(|v| v <= -1000.0)
458 - .unwrap_or(false)
453 + .is_some_and(|v| v <= -1000.0)
459 454 }
460 455
461 456 /// Drop infinite animations faster than 2s (strobe guard, decision #3). The
@@ -463,7 +458,7 @@ fn is_offscreen_text_indent(norm: &str) -> bool {
463 458 /// everyone else.
464 459 fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) {
465 460 /// A finite iteration-count this high at a sub-2s duration strobes just like
466 - /// `infinite` does (UX-M5) — `infinite` was the only case caught before.
461 + /// `infinite` does (UX-M5), `infinite` was the only case caught before.
467 462 const STROBE_MAX_ITERATIONS: f32 = 20.0;
468 463
469 464 let mut has_infinite = false;
@@ -489,10 +484,8 @@ fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<R
489 484 }
490 485 }
491 486
492 - let fast = min_duration.map(|d| d < 2.0).unwrap_or(false);
493 - let high_count = max_iterations
494 - .map(|n| n >= STROBE_MAX_ITERATIONS)
495 - .unwrap_or(false);
487 + let fast = min_duration.is_some_and(|d| d < 2.0);
488 + let high_count = max_iterations.is_some_and(|n| n >= STROBE_MAX_ITERATIONS);
496 489 let strobe = (has_infinite || high_count) && fast;
497 490 if !strobe {
498 491 return;
@@ -719,7 +712,7 @@ mod tests {
719 712 fn reduced_motion_appended() {
720 713 let out = scoped("p { color: red }");
721 714 assert!(out.contains("prefers-reduced-motion"));
722 - assert!(out.trim_end().ends_with("}"));
715 + assert!(out.trim_end().ends_with('}'));
723 716 }
724 717
725 718 #[test]
@@ -756,7 +749,7 @@ mod tests {
756 749
757 750 #[test]
758 751 fn fast_low_finite_count_animation_kept() {
759 - // A handful of iterations at a fast duration is fine — not a strobe.
752 + // A handful of iterations at a fast duration is fine, not a strobe.
760 753 let (out, rej) = san(".spin { animation: spin 1s linear 3 }");
761 754 assert!(out.to_lowercase().contains("animation"));
762 755 assert!(!rej.iter().any(|r| r.kind == RejectionKind::AnimationBudget));
@@ -857,10 +850,10 @@ mod tests {
857 850 // (templates/custom/*.html). The most direct stored-XSS attempt is a
858 851 // declaration whose value is a string closing the tag and opening a
859 852 // script. The serializer must never emit a literal `</style>` (or a bare
860 - // `<script>`) — `<` inside a CSS string token has to come back escaped.
853 + // `<script>`), `<` inside a CSS string token has to come back escaped.
861 854 for css in [
862 855 r#".x { content: "</style><script>alert(1)</script>" }"#,
863 - r#".x::before { content: '</STYLE><SCRIPT>alert(1)</SCRIPT>' }"#,
856 + r".x::before { content: '</STYLE><SCRIPT>alert(1)</SCRIPT>' }",
864 857 r#".x { content: "\3c /style\3e <script>" }"#,
865 858 // url() is dropped (external) but the string form must also be safe.
866 859 r#".x { background: url("</style><script>x</script>") }"#,
@@ -881,7 +874,7 @@ mod tests {
881 874 // ── Newer blocked at-rules (the UX-S2 exhaustive-match additions) ──
882 875 //
883 876 // The match in `visit_rule` has no wildcard arm, so a future lightningcss
884 - // variant fails to COMPILE until triaged — the compiler enforces that no
877 + // variant fails to COMPILE until triaged, the compiler enforces that no
885 878 // variant is both blocked and allowed (a variant can't appear in two arms of
886 879 // one match). These tests pin the RUNTIME behavior the compile-check can't:
887 880 // that lightningcss parses each of these at-rules into the variant the match
@@ -897,7 +890,7 @@ mod tests {
897 890 rej.iter().any(|r| r.kind == RejectionKind::BlockedAtRule),
898 891 "no BlockedAtRule rejection recorded for `{marker}`"
899 892 );
900 - // A sibling plain rule still survives — only the at-rule is dropped.
893 + // A sibling plain rule still survives, only the at-rule is dropped.
901 894 assert!(out.contains("color"), "sibling style rule was lost: {out}");
902 895 }
903 896
@@ -279,12 +279,12 @@ mod tests {
279 279
280 280 /// Seal for the hand-maintained `url_attribute` allowlist. The sanitizer's
281 281 /// safety rests on the documented invariant that the attribute filter covers
282 - /// *every* URL-bearing attribute on *every* allowed tag — an uncovered one
282 + /// *every* URL-bearing attribute on *every* allowed tag, an uncovered one
283 283 /// would let a `javascript:` URL through ammonia (which we deliberately let
284 284 /// candidate schemes past, so our filter is the sole authority). This fails
285 285 /// the build if the allowlist (`ALLOWED_TAGS` × `GENERIC_ATTRS` ∪ per-tag)
286 286 /// ever permits a known URL-bearing attribute that `url_attribute` doesn't
287 - /// recognise — keeping the two in sync by construction.
287 + /// recognise, keeping the two in sync by construction.
288 288 #[test]
289 289 fn url_attribute_covers_every_allowed_url_bearing_attribute() {
290 290 // The canonical set of HTML attributes that carry a URL. If any of these
M server/src/lib.rs +24 -26
M server/src/main.rs +24 -24
M server/src/rss.rs +45 -41