max / makenotwork
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" |
| @@ -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") |
| @@ -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"); |
| @@ -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 |