max / makenotwork
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
73 files changed,
+4272 insertions,
-1354 deletions
| @@ -15,6 +15,15 @@ | |||
| 15 | 15 | *.swo | |
| 16 | 16 | *~ | |
| 17 | 17 | ||
| 18 | + | # Secrets and credentials | |
| 19 | + | *.pem | |
| 20 | + | *.key | |
| 21 | + | *.p8 | |
| 22 | + | *.p12 | |
| 23 | + | *.pfx | |
| 24 | + | credentials.json | |
| 25 | + | service-account.json | |
| 26 | + | ||
| 18 | 27 | # OS files | |
| 19 | 28 | .DS_Store | |
| 20 | 29 | Thumbs.db |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ## Status | |
| 4 | 4 | Done: All pre-beta phases. Active: Creator setup (Stripe), manual testing. Next: Soft launch. | |
| 5 | 5 | ||
| 6 | - | v0.4.3. Audit grade A. ~1,412 tests. | |
| 6 | + | v0.4.4. Audit grade A- (Run 16, 2026-04-29). 727 unit tests + integration suite. | |
| 7 | 7 | ||
| 8 | 8 | --- | |
| 9 | 9 | ||
| @@ -136,6 +136,91 @@ | |||
| 136 | 136 | - Rate limit IP extraction trusts X-Forwarded-For when traffic bypasses Cloudflare (helpers.rs). Fix requires splitting rate limit extraction by path: CF-Connecting-IP for public web routes, peer socket for internal/CLI/git. Needs careful routing since CLI, git smart HTTP, and SyncKit all hit the same server but some bypass Cloudflare. | |
| 137 | 137 | - S3 key/file size UPDATE queries lack ownership in SQL -- defense-in-depth; callers verify ownership (db/items.rs) | |
| 138 | 138 | ||
| 139 | + | ## Audit Run 16 (2026-04-29) | |
| 140 | + | ||
| 141 | + | Overall grade: A- -> A (post-remediation). 75.5k LOC, 986 unit tests (13.1 tests/KLOC). 40+ findings resolved. | |
| 142 | + | ||
| 143 | + | ### Critical Fixes | |
| 144 | + | - [x] `bundles.rs::is_bundle_member` wrong column name (`child_item_id` -> `item_id`) | |
| 145 | + | - [x] Test harness broken — added `scan_semaphore` to `AppState` in test harness + load runner | |
| 146 | + | ||
| 147 | + | ### Testing | |
| 148 | + | - [x] **Scheduler tests** — extracted `jobs_for_tick()`, `is_webhook_dead()`, named constants. 15 unit tests. scheduler.rs: 0 -> 15 tests. | |
| 149 | + | - [x] **Cents tests** — 11 tests for formatting, arithmetic, conversions, serde roundtrip. | |
| 150 | + | ||
| 151 | + | ### Type Safety | |
| 152 | + | - [x] **`Cents` newtype** — `Cents(i64)` for all monetary values. 35+ fields across 15 files. Arithmetic, sqlx, serde, formatting methods. `PriceCents` converts via `From`. | |
| 153 | + | - [x] **SessionUser.creator_tier** — `Option<String>` -> `Option<CreatorTier>`. | |
| 154 | + | - [x] **OnboardingStep enum** — replaced magic integers 1/2/3. | |
| 155 | + | - [x] **format_price** — `i32` -> `impl Into<i64>`, unified with `Cents`. | |
| 156 | + | ||
| 157 | + | ### Performance (A-) | |
| 158 | + | - [x] **items.rs `move_item` N+1** — replaced N individual UPDATEs with single UNNEST batch update. | |
| 159 | + | - [x] **bundles.rs `set_bundle_items` loop insert** — replaced loop insert with single UNNEST batch insert. | |
| 160 | + | - [x] **follows.rs `NOT IN` anti-pattern** — replaced `NOT IN (SELECT LOWER(email) FROM email_suppressions)` with `NOT EXISTS` in both `get_follower_emails` and `get_broadcast_follower_count`. | |
| 161 | + | - [x] **creator_tiers.rs `get_storage_breakdown`** — replaced 6 sequential queries with a single CTE query returning all 6 category totals. | |
| 162 | + | - [x] **scheduler.rs `recalculate_all_storage_used`** — replaced N+1 per-user loop with single batch `UPDATE ... FROM (LATERAL joins)` query. Removed dead `recalculate_storage_used` and `get_all_creator_user_ids` functions. | |
| 163 | + | - [x] **auth.rs session touch** — extended `touch_session` to return `is_fan_plus` and `creator_tier` via subqueries, eliminating 2 extra DB round-trips on every uncached request. | |
| 164 | + | - [x] **discover.rs trigram scaling** — removed description from search clauses (titles only). Re-add description search later with proper full-text search index. | |
| 165 | + | ||
| 166 | + | ### Observability (A-) | |
| 167 | + | - [x] **storage.rs** — added warning log when `delete_prefix` default no-op is called. | |
| 168 | + | - [x] **config.rs startup log** — added structured info log of active features (s3, synckit_s3, stripe, scanner, mt, wam, git) in main.rs before scheduler start. | |
| 169 | + | - [x] **auth.rs** — added `#[instrument]` on `login_user`, `logout_user`, and `track_session`. | |
| 170 | + | ||
| 171 | + | ### Architecture (A-) | |
| 172 | + | - [ ] **scheduler.rs** — does too many things (publishing, email, cleanup, integrity checks, webhook retry). Consider splitting into submodules (scheduler/publishing.rs, scheduler/cleanup.rs, scheduler/integrity.rs) in a future pass. | |
| 173 | + | - [x] **scheduler.rs cleanup duplication** — extracted `cleanup_user_s3_and_delete` shared by sandbox, terminated, and content-removal cleanup. Also unified SyncKit/OTA cleanup into the shared helper (previously only sandbox had it). | |
| 174 | + | ||
| 175 | + | ### Codebase Size (A-) | |
| 176 | + | - [x] **exports.rs** — extracted `download_response()` helper, replacing 6 identical `Response::builder()` blocks. | |
| 177 | + | - [x] **auth.rs login notification** — extracted `maybe_send_login_notification()` in `auth.rs`, replacing duplicated code in password and passkey login paths. Also uses `extract_client_ip` instead of inline XFF parsing. | |
| 178 | + | - [x] **auth.rs SessionUser construction** — extracted `SessionUser::from_db_user()`, replacing 5 identical construction blocks across password, passkey, 2FA, and email-link login paths. | |
| 179 | + | - [x] **types/conversions.rs duration formatting** — extracted `format_duration()`, replacing duplicated logic for audio and video. | |
| 180 | + | - [ ] **templates/public.rs HealthTemplate** — ~90 fields. Consider grouping into sub-structs (HealthDbStatus, HealthStripeStatus, etc.). | |
| 181 | + | - [ ] **checkout.rs** (783 lines) — 6 repetitive `from_session` metadata extraction patterns. Consider a macro or shared trait. | |
| 182 | + | - [x] **email/tokens.rs** — truncated HMAC already documented (lines 268-271). No action needed. | |
| 183 | + | - [ ] **discover.rs** — code duplication across 3 search clause variants (short/long/none). Could reduce with a query builder. | |
| 184 | + | - [x] **guest_checkout.rs** — moved inline SQL to `db::transactions::create_free_guest_transaction` and `db::users::get_verified_user_id_by_email`. | |
| 185 | + | ||
| 186 | + | ### Testing (A- -> A) | |
| 187 | + | - [x] **error.rs** — 18 new tests: IntoResponse rendering for all variants, ResultExt context, internal error masking. (9 -> 27) | |
| 188 | + | - [x] **creator_tiers.rs** — 36 new tests: tier labels/prices/limits, format_bytes, StorageBreakdown. (0 -> 36) | |
| 189 | + | - [x] **conversions.rs** — 12 new tests: format_duration edge cases. (0 -> 12) | |
| 190 | + | - [x] **constants.rs** — 68 new tests: canary tests for all constants, ordering invariants, sanity bounds. (0 -> 68) | |
| 191 | + | - [x] **promo_codes.rs** — 15 new tests: percentage/fixed discount edge cases, overflow, clamping. (5 -> 20) | |
| 192 | + | - [x] **monitor.rs** — 11 new tests: status determination, alert transitions, content checks. (4 -> 15) | |
| 193 | + | - [x] **csv_converter.rs** — 30 new tests: empty CSV, special chars, price parsing, date parsing, unicode, long fields. (18 -> 48) | |
| 194 | + | - [x] **license_templates.rs** — 22 new tests: all presets, variable substitution, edge cases. (8 -> 30) | |
| 195 | + | - [x] **csrf.rs** — 13 new tests: token generation, verification, tampering, expiry, format. (6 -> 19) | |
| 196 | + | - [x] **rss.rs** — 15 new tests: XML escaping, empty feeds, all feed types, date format. (5 -> 20) | |
| 197 | + | - [x] **models/item.rs** — 8 new tests: computed fields, display formatting. (7 -> 15) | |
| 198 | + | - [ ] **lib.rs / main.rs** — no unit tests (covered by integration tests). | |
| 199 | + | ||
| 200 | + | ### Resilience (A-) | |
| 201 | + | - [x] **storage.rs `delete_prefix`** — added warning log when the default no-op is called. | |
| 202 | + | ||
| 203 | + | ### Frontend (A-) | |
| 204 | + | - [ ] **templates/partials.rs** — some template structs lack doc comments (TagTemplate, LinkRowTemplate, etc.). | |
| 205 | + | - [x] **email/notifications.rs** — fixed stale doc comment on line 442 (was "Send an alert email" above `send_tip_notification`). | |
| 206 | + | - [x] **checkout.rs tip truncation** — updated from 280 to 500 chars (Stripe metadata limit). | |
| 207 | + | ||
| 208 | + | ### Dependencies (B+) | |
| 209 | + | - [ ] **Bump rand to 0.9.x** — one major version behind, API changes required. | |
| 210 | + | - [ ] **CONCURRENTLY index strategy** — no migrations use `CREATE INDEX CONCURRENTLY`. Plan for this before tables grow large (transactions, items, users). | |
| 211 | + | - [x] **.gitignore** — added secret-file pattern exclusions (`.pem`, `.key`, `.p8`, `.p12`, `.pfx`, `credentials.json`, `service-account.json`). | |
| 212 | + | ||
| 213 | + | ### Accepted (no action needed) | |
| 214 | + | - `git/raw.rs` `.unwrap()` on Response builders — safe (static headers), cosmetic | |
| 215 | + | - `promo_codes.rs` `.unwrap()` on `and_hms_opt(23,59,59)` — safe (static args) | |
| 216 | + | - analytics.rs 6 near-identical query blocks — correct and safe, query builder would add complexity | |
| 217 | + | - Inline styles (124 occurrences) — most are functional (dynamic widths, conditional visibility) | |
| 218 | + | - helpers.rs / pricing.rs observability B+ — pure functions, silence is appropriate | |
| 219 | + | - `enums.rs` size A- (1397 lines) — ~500 lines are tests, the rest is exhaustive enum definitions | |
| 220 | + | - `users.rs` / `items.rs` size A- — large but each function is focused, no extraction needed | |
| 221 | + | ||
| 222 | + | --- | |
| 223 | + | ||
| 139 | 224 | ## Sandbox Fuzz Findings (2026-04-28) | |
| 140 | 225 | ||
| 141 | 226 | Four-agent adversarial audit of sandbox feature. 12 findings: mechanical fixes applied inline, remainder tracked below. |
| @@ -56,7 +56,7 @@ | |||
| 56 | 56 | #[serde(default)] | |
| 57 | 57 | pub is_fan_plus: bool, | |
| 58 | 58 | #[serde(default)] | |
| 59 | - | pub creator_tier: Option<String>, | |
| 59 | + | pub creator_tier: Option<db::CreatorTier>, | |
| 60 | 60 | #[serde(default)] | |
| 61 | 61 | pub deactivated: bool, | |
| 62 | 62 | #[serde(default)] | |
| @@ -64,6 +64,40 @@ | |||
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | impl SessionUser { | |
| 67 | + | /// Build a `SessionUser` from a DB user row + async lookups for fan_plus and creator_tier. | |
| 68 | + | /// | |
| 69 | + | /// Used by all login paths (password, passkey, 2FA, email link) except the join wizard | |
| 70 | + | /// (which uses hardcoded defaults for a freshly created account). | |
| 71 | + | pub async fn from_db_user( | |
| 72 | + | user: db::DbUser, | |
| 73 | + | pool: &sqlx::PgPool, | |
| 74 | + | admin_user_id: Option<db::UserId>, | |
| 75 | + | ) -> Self { | |
| 76 | + | let suspended = user.is_suspended(); | |
| 77 | + | let deactivated = user.is_deactivated(); | |
| 78 | + | let is_admin = admin_user_id == Some(user.id); | |
| 79 | + | let is_fan_plus = db::fan_plus::is_fan_plus_active(pool, user.id) | |
| 80 | + | .await | |
| 81 | + | .unwrap_or(false); | |
| 82 | + | let creator_tier = db::creator_tiers::get_active_creator_tier(pool, user.id) | |
| 83 | + | .await | |
| 84 | + | .ok() | |
| 85 | + | .flatten(); | |
| 86 | + | Self { | |
| 87 | + | id: user.id, | |
| 88 | + | username: user.username, | |
| 89 | + | email: user.email, | |
| 90 | + | display_name: user.display_name, | |
| 91 | + | can_create_projects: user.can_create_projects, | |
| 92 | + | suspended, | |
| 93 | + | is_admin, | |
| 94 | + | is_fan_plus, | |
| 95 | + | creator_tier, | |
| 96 | + | deactivated, | |
| 97 | + | is_sandbox: user.is_sandbox, | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 67 | 101 | /// Returns `Err(Forbidden)` if the user is a sandbox account. | |
| 68 | 102 | /// Call at the top of routes that sandbox users must not access (Stripe, email, etc.). | |
| 69 | 103 | pub fn check_not_sandbox(&self) -> Result<(), AppError> { | |
| @@ -130,7 +164,7 @@ | |||
| 130 | 164 | Ok(r) => r, | |
| 131 | 165 | Err(e) => { | |
| 132 | 166 | tracing::warn!(error = ?e, "session touch failed, invalidating"); | |
| 133 | - | db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false } | |
| 167 | + | db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None } | |
| 134 | 168 | } | |
| 135 | 169 | }; | |
| 136 | 170 | if !result.valid { | |
| @@ -138,21 +172,15 @@ | |||
| 138 | 172 | let _ = session.flush().await; | |
| 139 | 173 | return Err(AppError::Unauthorized); | |
| 140 | 174 | } | |
| 141 | - | // If the user's suspended status changed since login, update the | |
| 142 | - | // session so check_not_suspended() reflects the live DB value. | |
| 143 | - | let is_fan_plus = db::fan_plus::is_fan_plus_active(&state.db, user.id) | |
| 144 | - | .await | |
| 145 | - | .unwrap_or(false); | |
| 146 | - | let creator_tier = db::creator_tiers::get_active_creator_tier(&state.db, user.id) | |
| 147 | - | .await | |
| 148 | - | .ok() | |
| 149 | - | .flatten() | |
| 150 | - | .map(|t| t.to_string()); | |
| 151 | - | if user.suspended != result.suspended || user.is_fan_plus != is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != creator_tier { | |
| 175 | + | // If the user's live DB state differs from the session, update it. | |
| 176 | + | // touch_session returns suspended, can_create_projects, is_fan_plus, | |
| 177 | + | // and creator_tier in a single query (no extra round-trips). | |
| 178 | + | let live_tier: Option<db::CreatorTier> = result.creator_tier.as_deref().and_then(|s| s.parse().ok()); | |
| 179 | + | if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier { | |
| 152 | 180 | user.suspended = result.suspended; | |
| 153 | - | user.is_fan_plus = is_fan_plus; | |
| 181 | + | user.is_fan_plus = result.is_fan_plus; | |
| 154 | 182 | user.can_create_projects = result.can_create_projects; | |
| 155 | - | user.creator_tier = creator_tier; | |
| 183 | + | user.creator_tier = live_tier; | |
| 156 | 184 | if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await { | |
| 157 | 185 | tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state"); | |
| 158 | 186 | } | |
| @@ -274,6 +302,7 @@ | |||
| 274 | 302 | } | |
| 275 | 303 | ||
| 276 | 304 | /// Store user in session with session regeneration to prevent fixation attacks | |
| 305 | + | #[tracing::instrument(skip_all, fields(user_id = %user.id))] | |
| 277 | 306 | pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> { | |
| 278 | 307 | // Regenerate session ID to prevent session fixation attacks | |
| 279 | 308 | // This creates a new session ID while preserving session data | |
| @@ -297,6 +326,7 @@ | |||
| 297 | 326 | } | |
| 298 | 327 | ||
| 299 | 328 | /// Destroy entire session on logout to prevent session reuse | |
| 329 | + | #[tracing::instrument(skip_all)] | |
| 300 | 330 | pub async fn logout_user(session: &Session) -> Result<(), AppError> { | |
| 301 | 331 | // Flush the entire session to destroy all data and invalidate session ID | |
| 302 | 332 | session | |
| @@ -308,6 +338,7 @@ | |||
| 308 | 338 | ||
| 309 | 339 | /// Record a new session in `user_sessions` and store the tracking ID in session data. | |
| 310 | 340 | /// Call this after `login_user()` in every login path. | |
| 341 | + | #[tracing::instrument(skip_all, fields(user_id = %user_id))] | |
| 311 | 342 | pub async fn track_session( | |
| 312 | 343 | session: &Session, | |
| 313 | 344 | pool: &PgPool, | |
| @@ -332,6 +363,56 @@ | |||
| 332 | 363 | Ok(()) | |
| 333 | 364 | } | |
| 334 | 365 | ||
| 366 | + | /// Send a new-device login notification if the user has other active sessions. | |
| 367 | + | /// | |
| 368 | + | /// Fire-and-forget — spawns a background task. Only sends if the user has opted in | |
| 369 | + | /// and has more than one active session (meaning this is a new device). | |
| 370 | + | pub async fn maybe_send_login_notification( | |
| 371 | + | state: &crate::AppState, | |
| 372 | + | user_id: UserId, | |
| 373 | + | email: &str, | |
| 374 | + | display_name: Option<&str>, | |
| 375 | + | enabled: bool, | |
| 376 | + | headers: &HeaderMap, | |
| 377 | + | ) { | |
| 378 | + | if !enabled { | |
| 379 | + | return; | |
| 380 | + | } | |
| 381 | + | let session_count = match db::sessions::count_user_sessions(&state.db, user_id).await { | |
| 382 | + | Ok(n) => n, | |
| 383 | + | Err(e) => { | |
| 384 | + | tracing::warn!("Failed to count sessions for login notification: {e}"); | |
| 385 | + | return; | |
| 386 | + | } | |
| 387 | + | }; | |
| 388 | + | if session_count <= 1 { | |
| 389 | + | return; | |
| 390 | + | } | |
| 391 | + | let user_agent = headers | |
| 392 | + | .get("user-agent") | |
| 393 | + | .and_then(|v| v.to_str().ok()) | |
| 394 | + | .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>()); | |
| 395 | + | let ip = crate::helpers::extract_client_ip(headers); | |
| 396 | + | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 397 | + | &state.config.host_url, | |
| 398 | + | user_id, | |
| 399 | + | "login", | |
| 400 | + | &user_id.to_string(), | |
| 401 | + | &state.config.signing_secret, | |
| 402 | + | ); | |
| 403 | + | let email = email.to_string(); | |
| 404 | + | let display_name = display_name.map(String::from); | |
| 405 | + | crate::helpers::spawn_email!(state.email, "login notification", |email_client| { | |
| 406 | + | email_client.send_new_login_notification( | |
| 407 | + | &email, | |
| 408 | + | display_name.as_deref(), | |
| 409 | + | user_agent.as_deref(), | |
| 410 | + | ip.as_deref(), | |
| 411 | + | Some(&unsub_url), | |
| 412 | + | ) | |
| 413 | + | }); | |
| 414 | + | } | |
| 415 | + | ||
| 335 | 416 | /// Check if a password appears in the HaveIBeenPwned breached passwords database. | |
| 336 | 417 | /// Uses k-anonymity: only the first 5 characters of the SHA-1 hash are sent. | |
| 337 | 418 | /// Returns Some(count) if breached, None if clean or API unavailable. |
| @@ -72,6 +72,17 @@ | |||
| 72 | 72 | /// Called from the scheduler loop. Non-blocking — spawns the build task and returns. | |
| 73 | 73 | #[tracing::instrument(skip_all, name = "build_runner::dispatch")] | |
| 74 | 74 | pub async fn dispatch_pending_build(state: &AppState) { | |
| 75 | + | // Recover from stale running builds (e.g. server crashed mid-build) | |
| 76 | + | match db::builds::fail_stale_running_builds(&state.db, BUILD_TIMEOUT_SECS as i64).await { | |
| 77 | + | Ok(n) if n > 0 => { | |
| 78 | + | tracing::warn!(count = n, "marked stale running builds as failed"); | |
| 79 | + | } | |
| 80 | + | Err(e) => { | |
| 81 | + | tracing::error!(error = ?e, "failed to check stale builds"); | |
| 82 | + | } | |
| 83 | + | _ => {} | |
| 84 | + | } | |
| 85 | + | ||
| 75 | 86 | let has_running = match db::builds::has_running_build(&state.db).await { | |
| 76 | 87 | Ok(r) => r, | |
| 77 | 88 | Err(e) => { | |
| @@ -310,7 +321,16 @@ | |||
| 310 | 321 | .replace("{target}", rust_triple) | |
| 311 | 322 | .replace("{version}", &build.version); | |
| 312 | 323 | ||
| 324 | + | // Validate build_command and artifact_path before interpolation into shell | |
| 325 | + | validate_build_command(&build_cmd) | |
| 326 | + | .map_err(|e| format!("invalid build command: {e}"))?; | |
| 327 | + | validate_artifact_path(&artifact_path) | |
| 328 | + | .map_err(|e| format!("invalid artifact path: {e}"))?; | |
| 329 | + | ||
| 313 | 330 | // Build the SSH command sequence | |
| 331 | + | // Note: build_cmd is validated (no shell metacharacters beyond safe set) but | |
| 332 | + | // intentionally NOT shell-escaped since it must execute as a shell command. | |
| 333 | + | // artifact_path is validated AND shell-escaped since it's used as a file path. | |
| 314 | 334 | let remote_script = format!( | |
| 315 | 335 | "set -e && \ | |
| 316 | 336 | git clone --depth 1 --branch {tag} {clone_path} {build_dir} && \ | |
| @@ -354,12 +374,12 @@ | |||
| 354 | 374 | ||
| 355 | 375 | // Copy artifact from remote to local temp | |
| 356 | 376 | let local_tmp = format!("/tmp/mnw-artifact-{}-{target_os}-{arch}", build.id); | |
| 357 | - | let scp_result = run_scp_download( | |
| 358 | - | host, | |
| 359 | - | &format!("{build_dir}/{artifact_path}"), | |
| 360 | - | &local_tmp, | |
| 361 | - | ) | |
| 362 | - | .await; | |
| 377 | + | let scp_remote_path = format!( | |
| 378 | + | "{}/{}", | |
| 379 | + | build_dir.trim_end_matches('/'), | |
| 380 | + | artifact_path.trim_start_matches('/') | |
| 381 | + | ); | |
| 382 | + | let scp_result = run_scp_download(host, &scp_remote_path, &local_tmp).await; | |
| 363 | 383 | ||
| 364 | 384 | // Cleanup remote build dir | |
| 365 | 385 | let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await; | |
| @@ -476,6 +496,82 @@ | |||
| 476 | 496 | db::builds::append_build_log(&state.db, build_id, line).await | |
| 477 | 497 | } | |
| 478 | 498 | ||
| 499 | + | /// Validate a build command for shell safety. | |
| 500 | + | /// | |
| 501 | + | /// Rejects shell metacharacters that enable command chaining or redirection. | |
| 502 | + | /// Allowed: alphanumeric, spaces, hyphens, underscores, dots, slashes, equals, | |
| 503 | + | /// braces (for template vars), colons, commas, plus signs. | |
| 504 | + | pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> { | |
| 505 | + | if cmd.is_empty() { | |
| 506 | + | return Err("build command is empty".to_string()); | |
| 507 | + | } | |
| 508 | + | if cmd.len() > 1024 { | |
| 509 | + | return Err("build command too long (max 1024 chars)".to_string()); | |
| 510 | + | } | |
| 511 | + | for (i, c) in cmd.chars().enumerate() { | |
| 512 | + | match c { | |
| 513 | + | 'a'..='z' | 'A'..='Z' | '0'..='9' => {} | |
| 514 | + | ' ' | '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {} | |
| 515 | + | ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' | |
| 516 | + | | '\n' | '\r' | '\0' => { | |
| 517 | + | return Err(format!( | |
| 518 | + | "shell metacharacter '{}' at position {} is not allowed", | |
| 519 | + | c.escape_default(), | |
| 520 | + | i | |
| 521 | + | )); | |
| 522 | + | } | |
| 523 | + | _ => { | |
| 524 | + | return Err(format!( | |
| 525 | + | "unexpected character '{}' at position {} is not allowed", | |
| 526 | + | c.escape_default(), | |
| 527 | + | i | |
| 528 | + | )); | |
| 529 | + | } | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | Ok(()) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | /// Validate an artifact path for shell and path safety. | |
| 536 | + | /// | |
| 537 | + | /// Must be a relative path with no shell metacharacters or path traversal. | |
| 538 | + | pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> { | |
| 539 | + | if path.is_empty() { | |
| 540 | + | return Err("artifact path is empty".to_string()); | |
| 541 | + | } | |
| 542 | + | if path.len() > 512 { | |
| 543 | + | return Err("artifact path too long (max 512 chars)".to_string()); | |
| 544 | + | } | |
| 545 | + | if path.starts_with('/') { | |
| 546 | + | return Err("artifact path must be relative".to_string()); | |
| 547 | + | } | |
| 548 | + | if path.contains("..") { | |
| 549 | + | return Err("artifact path must not contain '..'".to_string()); | |
| 550 | + | } | |
| 551 | + | for (i, c) in path.chars().enumerate() { | |
| 552 | + | match c { | |
| 553 | + | 'a'..='z' | 'A'..='Z' | '0'..='9' => {} | |
| 554 | + | '-' | '_' | '.' | '/' | '{' | '}' | '+' => {} | |
| 555 | + | ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' | |
| 556 | + | | ' ' | '\n' | '\r' | '\0' => { | |
| 557 | + | return Err(format!( | |
| 558 | + | "character '{}' at position {} is not allowed in artifact path", | |
| 559 | + | c.escape_default(), | |
| 560 | + | i | |
| 561 | + | )); | |
| 562 | + | } | |
| 563 | + | _ => { | |
| 564 | + | return Err(format!( | |
| 565 | + | "unexpected character '{}' at position {} is not allowed in artifact path", | |
| 566 | + | c.escape_default(), | |
| 567 | + | i | |
| 568 | + | )); | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | Ok(()) | |
| 573 | + | } | |
| 574 | + | ||
| 479 | 575 | /// Escape a string for safe use in a shell command. | |
| 480 | 576 | fn shell_escape(s: &str) -> String { | |
| 481 | 577 | format!("'{}'", s.replace('\'', "'\\''")) | |
| @@ -507,4 +603,37 @@ | |||
| 507 | 603 | assert_eq!(shell_escape("hello"), "'hello'"); | |
| 508 | 604 | assert_eq!(shell_escape("it's"), "'it'\\''s'"); | |
| 509 | 605 | } | |
| 606 | + | ||
| 607 | + | #[test] | |
| 608 | + | fn validate_build_command_accepts_safe_commands() { | |
| 609 | + | assert!(validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu").is_ok()); | |
| 610 | + | assert!(validate_build_command("make -j4").is_ok()); | |
| 611 | + | assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok()); | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | #[test] | |
| 615 | + | fn validate_build_command_rejects_injection() { | |
| 616 | + | assert!(validate_build_command("cargo build; curl evil.com").is_err()); | |
| 617 | + | assert!(validate_build_command("cargo build && rm -rf /").is_err()); | |
| 618 | + | assert!(validate_build_command("cargo build | tee log").is_err()); | |
| 619 | + | assert!(validate_build_command("$(whoami)").is_err()); | |
| 620 | + | assert!(validate_build_command("`whoami`").is_err()); | |
| 621 | + | assert!(validate_build_command("cargo build > /dev/null").is_err()); | |
| 622 | + | assert!(validate_build_command("").is_err()); | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | #[test] | |
| 626 | + | fn validate_artifact_path_accepts_safe_paths() { | |
| 627 | + | assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok()); | |
| 628 | + | assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok()); | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | #[test] | |
| 632 | + | fn validate_artifact_path_rejects_unsafe() { | |
| 633 | + | assert!(validate_artifact_path("/etc/passwd").is_err()); | |
| 634 | + | assert!(validate_artifact_path("../../../etc/passwd").is_err()); | |
| 635 | + | assert!(validate_artifact_path("path with spaces").is_err()); | |
| 636 | + | assert!(validate_artifact_path("$(whoami)").is_err()); | |
| 637 | + | assert!(validate_artifact_path("").is_err()); | |
| 638 | + | } | |
| 510 | 639 | } |
| @@ -199,3 +199,402 @@ | |||
| 199 | 199 | pub const SANDBOX_RATE_LIMIT_BURST: u32 = 2; | |
| 200 | 200 | /// Max concurrent active sandboxes per IP. | |
| 201 | 201 | pub const SANDBOX_MAX_PER_IP: i64 = 3; | |
| 202 | + | ||
| 203 | + | #[cfg(test)] | |
| 204 | + | mod tests { | |
| 205 | + | use super::*; | |
| 206 | + | ||
| 207 | + | // -- Price constants -- | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn max_price_cents_is_positive() { | |
| 211 | + | assert!(MAX_PRICE_CENTS > 0); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn max_price_cents_sane_upper_bound() { | |
| 216 | + | // Should not exceed $100,000 | |
| 217 | + | assert!(MAX_PRICE_CENTS <= 10_000_000); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[test] | |
| 221 | + | fn min_subscription_price_positive() { | |
| 222 | + | assert!(MIN_SUBSCRIPTION_PRICE_CENTS > 0); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn min_subscription_price_below_max() { | |
| 227 | + | assert!(MIN_SUBSCRIPTION_PRICE_CENTS < MAX_PRICE_CENTS); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // -- Stripe fee constants -- | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn stripe_fee_percentage_reasonable() { | |
| 234 | + | assert!(STRIPE_FEE_PERCENTAGE > 0.0); | |
| 235 | + | assert!(STRIPE_FEE_PERCENTAGE < 0.5); // less than 50% | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[test] | |
| 239 | + | fn stripe_fee_fixed_positive() { | |
| 240 | + | assert!(STRIPE_FEE_FIXED_CENTS > 0.0); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // -- Database pool -- | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn db_pool_max_exceeds_min() { | |
| 247 | + | assert!(DB_POOL_MAX_CONNECTIONS > DB_POOL_MIN_CONNECTIONS); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn db_pool_min_positive() { | |
| 252 | + | assert!(DB_POOL_MIN_CONNECTIONS > 0); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | #[test] | |
| 256 | + | fn db_acquire_timeout_positive() { | |
| 257 | + | assert!(DB_ACQUIRE_TIMEOUT_SECS > 0); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | #[test] | |
| 261 | + | fn db_max_lifetime_exceeds_idle_timeout() { | |
| 262 | + | assert!(DB_MAX_LIFETIME_SECS > DB_IDLE_TIMEOUT_SECS); | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // -- Session constants -- | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn session_expiry_positive() { | |
| 269 | + | assert!(SESSION_EXPIRY_DAYS > 0); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn session_expiry_not_absurd() { | |
| 274 | + | assert!(SESSION_EXPIRY_DAYS <= 365); | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | #[test] | |
| 278 | + | fn session_touch_cache_positive() { | |
| 279 | + | assert!(SESSION_TOUCH_CACHE_SECS > 0); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn session_touch_cache_less_than_one_day() { | |
| 284 | + | assert!(SESSION_TOUCH_CACHE_SECS < 86400); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // -- Login security -- | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn max_login_attempts_positive() { | |
| 291 | + | assert!(MAX_LOGIN_ATTEMPTS > 0); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn lockout_minutes_positive() { | |
| 296 | + | assert!(LOCKOUT_MINUTES > 0); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // -- Email link expiry ordering -- | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn password_reset_expiry_positive() { | |
| 303 | + | assert!(PASSWORD_RESET_EXPIRY_SECS > 0); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn email_verification_longer_than_password_reset() { | |
| 308 | + | assert!(EMAIL_VERIFICATION_EXPIRY_SECS > PASSWORD_RESET_EXPIRY_SECS); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[test] | |
| 312 | + | fn account_deletion_expiry_positive() { | |
| 313 | + | assert!(ACCOUNT_DELETION_EXPIRY_SECS > 0); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // -- Scheduler -- | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn scheduler_interval_positive() { | |
| 320 | + | assert!(SCHEDULER_INTERVAL_SECS > 0); | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // -- Rate limit bursts all positive -- | |
| 324 | + | ||
| 325 | + | #[test] | |
| 326 | + | fn auth_rate_limit_burst_positive() { | |
| 327 | + | assert!(AUTH_RATE_LIMIT_BURST > 0); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | #[test] | |
| 331 | + | fn validate_rate_limit_burst_positive() { | |
| 332 | + | assert!(VALIDATE_RATE_LIMIT_BURST > 0); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | #[test] | |
| 336 | + | fn api_write_rate_limit_burst_positive() { | |
| 337 | + | assert!(API_WRITE_RATE_LIMIT_BURST > 0); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn api_read_rate_limit_burst_positive() { | |
| 342 | + | assert!(API_READ_RATE_LIMIT_BURST > 0); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn api_export_rate_limit_burst_positive() { | |
| 347 | + | assert!(API_EXPORT_RATE_LIMIT_BURST > 0); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn license_key_rate_limit_burst_positive() { | |
| 352 | + | assert!(LICENSE_KEY_RATE_LIMIT_BURST > 0); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[test] | |
| 356 | + | fn upload_rate_limit_burst_positive() { | |
| 357 | + | assert!(UPLOAD_RATE_LIMIT_BURST > 0); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | #[test] | |
| 361 | + | fn oauth_rate_limit_burst_positive() { | |
| 362 | + | assert!(OAUTH_RATE_LIMIT_BURST > 0); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn oauth_token_rate_limit_burst_positive() { | |
| 367 | + | assert!(OAUTH_TOKEN_RATE_LIMIT_BURST > 0); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // -- Rate limit burst ordering: read > write > auth -- | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn api_read_burst_exceeds_write_burst() { | |
| 374 | + | assert!(API_READ_RATE_LIMIT_BURST > API_WRITE_RATE_LIMIT_BURST); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | #[test] | |
| 378 | + | fn api_write_burst_exceeds_auth_burst() { | |
| 379 | + | assert!(API_WRITE_RATE_LIMIT_BURST > AUTH_RATE_LIMIT_BURST); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // -- Rate limit intervals positive -- | |
| 383 | + | ||
| 384 | + | #[test] | |
| 385 | + | fn auth_rate_limit_ms_positive() { | |
| 386 | + | assert!(AUTH_RATE_LIMIT_MS > 0); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[test] | |
| 390 | + | fn api_write_rate_limit_ms_positive() { | |
| 391 | + | assert!(API_WRITE_RATE_LIMIT_MS > 0); | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | #[test] | |
| 395 | + | fn api_read_rate_limit_ms_positive() { | |
| 396 | + | assert!(API_READ_RATE_LIMIT_MS > 0); | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // -- File size limits -- | |
| 400 | + | ||
| 401 | + | #[test] | |
| 402 | + | fn scan_max_memory_positive() { | |
| 403 | + | assert!(SCAN_MAX_MEMORY_BYTES > 0); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn scan_zip_max_uncompressed_exceeds_memory_threshold() { | |
| 408 | + | assert!(SCAN_ZIP_MAX_UNCOMPRESSED > SCAN_MAX_MEMORY_BYTES as u64); | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | #[test] | |
| 412 | + | fn git_raw_max_exceeds_file_display_limit() { | |
| 413 | + | assert!(GIT_RAW_MAX_BYTES > GIT_MAX_FILE_SIZE_BYTES); | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | #[test] | |
| 417 | + | fn scan_zip_max_ratio_positive() { | |
| 418 | + | assert!(SCAN_ZIP_MAX_RATIO > 0.0); | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | #[test] | |
| 422 | + | fn scan_zip_max_depth_positive() { | |
| 423 | + | assert!(SCAN_ZIP_MAX_DEPTH > 0); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // -- SyncKit -- | |
| 427 | + | ||
| 428 | + | #[test] | |
| 429 | + | fn synckit_push_max_changes_positive() { | |
| 430 | + | assert!(SYNCKIT_PUSH_MAX_CHANGES > 0); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | #[test] | |
| 434 | + | fn synckit_pull_page_size_positive() { | |
| 435 | + | assert!(SYNCKIT_PULL_PAGE_SIZE > 0); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | #[test] | |
| 439 | + | fn synckit_max_blob_size_positive() { | |
| 440 | + | assert!(SYNCKIT_MAX_BLOB_SIZE_BYTES > 0); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 444 | + | fn synckit_jwt_expiry_positive() { | |
| 445 | + | assert!(SYNCKIT_JWT_EXPIRY_SECS > 0); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // -- TOTP -- | |
| 449 | + | ||
| 450 | + | #[test] | |
| 451 | + | fn totp_digits_is_six() { | |
| 452 | + | assert_eq!(TOTP_DIGITS, 6); | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | #[test] | |
| 456 | + | fn totp_step_is_30() { | |
| 457 | + | assert_eq!(TOTP_STEP, 30); | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | #[test] | |
| 461 | + | fn backup_code_count_positive() { | |
| 462 | + | assert!(BACKUP_CODE_COUNT > 0); | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | #[test] | |
| 466 | + | fn backup_code_length_positive() { | |
| 467 | + | assert!(BACKUP_CODE_LENGTH > 0); | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // -- Pagination -- | |
| 471 | + | ||
| 472 | + | #[test] | |
| 473 | + | fn discover_page_size_positive() { | |
| 474 | + | assert!(DISCOVER_PAGE_SIZE > 0); | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | #[test] | |
| 478 | + | fn feed_page_size_positive() { | |
| 479 | + | assert!(FEED_PAGE_SIZE > 0); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[test] | |
| 483 | + | fn pagination_window_size_positive() { | |
| 484 | + | assert!(PAGINATION_WINDOW_SIZE > 0); | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // -- String constants non-empty -- | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn date_formats_non_empty() { | |
| 491 | + | assert!(!DATE_FMT_SHORT.is_empty()); | |
| 492 | + | assert!(!DATE_FMT_FULL.is_empty()); | |
| 493 | + | assert!(!DATE_FMT_ISO.is_empty()); | |
| 494 | + | assert!(!DATE_FMT_DATETIME.is_empty()); | |
| 495 | + | assert!(!DATE_FMT_DATETIME_UTC.is_empty()); | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | #[test] | |
| 499 | + | fn changelog_project_slug_non_empty() { | |
| 500 | + | assert!(!CHANGELOG_PROJECT_SLUG.is_empty()); | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | #[test] | |
| 504 | + | fn build_allowed_targets_non_empty() { | |
| 505 | + | assert!(!BUILD_ALLOWED_TARGETS.is_empty()); | |
| 506 | + | for target in BUILD_ALLOWED_TARGETS { | |
| 507 | + | assert!(!target.is_empty()); | |
| 508 | + | assert!(target.contains('/'), "target should be os/arch format: {}", target); | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // -- Collections -- | |
| 513 | + | ||
| 514 | + | #[test] | |
| 515 | + | fn max_collections_per_user_positive() { | |
| 516 | + | assert!(MAX_COLLECTIONS_PER_USER > 0); | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | #[test] | |
| 520 | + | fn max_items_per_collection_positive() { | |
| 521 | + | assert!(MAX_ITEMS_PER_COLLECTION > 0); | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | // -- Build pipeline -- | |
| 525 | + | ||
| 526 | + | #[test] | |
| 527 | + | fn build_timeout_positive() { | |
| 528 | + | assert!(BUILD_TIMEOUT_SECS > 0); | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | #[test] | |
| 532 | + | fn build_max_log_bytes_positive() { | |
| 533 | + | assert!(BUILD_MAX_LOG_BYTES > 0); | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // -- Health monitoring -- | |
| 537 | + | ||
| 538 | + | #[test] | |
| 539 | + | fn health_check_interval_positive() { | |
| 540 | + | assert!(HEALTH_CHECK_INTERVAL_SECS > 0); | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | #[test] | |
| 544 | + | fn alert_cooldown_exceeds_health_check() { | |
| 545 | + | assert!(ALERT_COOLDOWN_SECS > HEALTH_CHECK_INTERVAL_SECS); | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // -- Sandbox -- | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn sandbox_expiry_positive() { | |
| 552 | + | assert!(SANDBOX_EXPIRY_SECS > 0); | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | #[test] | |
| 556 | + | fn sandbox_cleanup_interval_positive() { | |
| 557 | + | assert!(SANDBOX_CLEANUP_INTERVAL_SECS > 0); | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | #[test] | |
| 561 | + | fn sandbox_cleanup_less_than_expiry() { | |
| 562 | + | assert!(SANDBOX_CLEANUP_INTERVAL_SECS < SANDBOX_EXPIRY_SECS as u64); | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | #[test] | |
| 566 | + | fn sandbox_max_per_ip_positive() { | |
| 567 | + | assert!(SANDBOX_MAX_PER_IP > 0); | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | // -- Webhook -- | |
| 571 | + | ||
| 572 | + | #[test] | |
| 573 | + | fn webhook_timestamp_tolerance_positive() { | |
| 574 | + | assert!(WEBHOOK_TIMESTAMP_TOLERANCE_SECS > 0); | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | // -- OAuth -- | |
| 578 | + | ||
| 579 | + | #[test] | |
| 580 | + | fn oauth_code_expiry_positive() { | |
| 581 | + | assert!(OAUTH_CODE_EXPIRY_SECS > 0); | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | #[test] | |
| 585 | + | fn oauth_code_length_positive() { | |
| 586 | + | assert!(OAUTH_CODE_LENGTH > 0); | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | // -- Buffer limits -- | |
| 590 | + | ||
| 591 | + | #[test] | |
| 592 | + | fn user_agent_max_length_positive() { | |
| 593 | + | assert!(USER_AGENT_MAX_LENGTH > 0); | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | #[test] | |
| 597 | + | fn synckit_max_key_envelope_bytes_positive() { | |
| 598 | + | assert!(SYNCKIT_MAX_KEY_ENVELOPE_BYTES > 0); | |
| 599 | + | } | |
| 600 | + | } |
| @@ -295,4 +295,108 @@ | |||
| 295 | 295 | let token = extract_token_from_request(&headers, None); | |
| 296 | 296 | assert!(token.is_none()); | |
| 297 | 297 | } | |
| 298 | + | ||
| 299 | + | #[test] | |
| 300 | + | fn test_generate_token_unique_across_many() { | |
| 301 | + | let tokens: Vec<String> = (0..100).map(|_| generate_token()).collect(); | |
| 302 | + | let unique: std::collections::HashSet<&String> = tokens.iter().collect(); | |
| 303 | + | assert_eq!(unique.len(), 100, "all 100 tokens should be unique"); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn test_generate_token_correct_byte_length() { | |
| 308 | + | let token = generate_token(); | |
| 309 | + | let bytes = hex::decode(&token).expect("token should be valid hex"); | |
| 310 | + | assert_eq!(bytes.len(), CSRF_TOKEN_LENGTH); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | #[test] | |
| 314 | + | fn test_extract_token_header_takes_priority_over_body() { | |
| 315 | + | let mut headers = HeaderMap::new(); | |
| 316 | + | headers.insert("X-CSRF-Token", "header_token".parse().unwrap()); | |
| 317 | + | let body = "_csrf=body_token"; | |
| 318 | + | let token = extract_token_from_request(&headers, Some(body)); | |
| 319 | + | assert_eq!(token.as_deref(), Some("header_token")); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[test] | |
| 323 | + | fn test_extract_token_from_body_url_encoded() { | |
| 324 | + | let headers = HeaderMap::new(); | |
| 325 | + | let body = "_csrf=token%20with%20spaces&other=val"; | |
| 326 | + | let token = extract_token_from_request(&headers, Some(body)); | |
| 327 | + | assert_eq!(token.as_deref(), Some("token with spaces")); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | #[test] | |
| 331 | + | fn test_extract_token_csrf_at_start_of_body() { | |
| 332 | + | let headers = HeaderMap::new(); | |
| 333 | + | let body = "_csrf=firstfield&name=value"; | |
| 334 | + | let token = extract_token_from_request(&headers, Some(body)); | |
| 335 | + | assert_eq!(token.as_deref(), Some("firstfield")); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | #[test] | |
| 339 | + | fn test_extract_token_csrf_at_end_of_body() { | |
| 340 | + | let headers = HeaderMap::new(); | |
| 341 | + | let body = "name=value&_csrf=lastfield"; | |
| 342 | + | let token = extract_token_from_request(&headers, Some(body)); | |
| 343 | + | assert_eq!(token.as_deref(), Some("lastfield")); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn test_extract_token_empty_body() { | |
| 348 | + | let headers = HeaderMap::new(); | |
| 349 | + | let token = extract_token_from_request(&headers, Some("")); | |
| 350 | + | assert!(token.is_none()); | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | #[test] | |
| 354 | + | fn test_extract_token_body_without_csrf_field() { | |
| 355 | + | let headers = HeaderMap::new(); | |
| 356 | + | let body = "name=value&other=data"; | |
| 357 | + | let token = extract_token_from_request(&headers, Some(body)); | |
| 358 | + | assert!(token.is_none()); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | #[test] | |
| 362 | + | fn test_extract_token_csrf_prefix_mismatch() { | |
| 363 | + | let headers = HeaderMap::new(); | |
| 364 | + | // Field named "_csrfx" should NOT match "_csrf=" | |
| 365 | + | let body = "_csrfx=notreal"; | |
| 366 | + | let token = extract_token_from_request(&headers, Some(body)); | |
| 367 | + | assert!(token.is_none()); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | #[test] | |
| 371 | + | fn test_extract_token_empty_csrf_value() { | |
| 372 | + | let headers = HeaderMap::new(); | |
| 373 | + | let body = "_csrf=&other=val"; | |
| 374 | + | let token = extract_token_from_request(&headers, Some(body)); | |
| 375 | + | assert_eq!(token.as_deref(), Some("")); | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | #[test] | |
| 379 | + | fn test_constant_time_compare_empty_strings() { | |
| 380 | + | use crate::helpers::constant_time_compare; | |
| 381 | + | assert!(constant_time_compare("", "")); | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | #[test] | |
| 385 | + | fn test_constant_time_compare_near_miss() { | |
| 386 | + | use crate::helpers::constant_time_compare; | |
| 387 | + | let token = generate_token(); | |
| 388 | + | // Flip last character | |
| 389 | + | let mut tampered = token.clone(); | |
| 390 | + | let last = tampered.pop().unwrap(); | |
| 391 | + | tampered.push(if last == '0' { '1' } else { '0' }); | |
| 392 | + | assert!(!constant_time_compare(&token, &tampered)); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | #[test] | |
| 396 | + | fn test_constant_time_compare_truncated() { | |
| 397 | + | use crate::helpers::constant_time_compare; | |
| 398 | + | let token = generate_token(); | |
| 399 | + | let truncated = &token[..token.len() - 1]; | |
| 400 | + | assert!(!constant_time_compare(&token, truncated)); | |
| 401 | + | } | |
| 298 | 402 | } |
| @@ -264,4 +264,184 @@ | |||
| 264 | 264 | let cloned = msg.clone(); | |
| 265 | 265 | assert_eq!(cloned.0, "test error"); | |
| 266 | 266 | } | |
| 267 | + | ||
| 268 | + | // ── IntoResponse rendering ────────────────────────────────────────── | |
| 269 | + | ||
| 270 | + | fn response_status_and_body(err: AppError) -> (StatusCode, String, Option<ApiErrorMessage>) { | |
| 271 | + | let response = err.into_response(); | |
| 272 | + | let status = response.status(); | |
| 273 | + | let api_msg = response.extensions().get::<ApiErrorMessage>().cloned(); | |
| 274 | + | // We can't easily extract the body synchronously, but we can verify | |
| 275 | + | // the status and the stashed ApiErrorMessage extension. | |
| 276 | + | (status, api_msg.as_ref().map(|m| m.0.clone()).unwrap_or_default(), api_msg) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[test] | |
| 280 | + | fn into_response_not_found() { | |
| 281 | + | let (status, body, ext) = response_status_and_body(AppError::NotFound); | |
| 282 | + | assert_eq!(status, StatusCode::NOT_FOUND); | |
| 283 | + | assert!(body.contains("doesn't exist")); | |
| 284 | + | assert!(ext.is_some()); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[test] | |
| 288 | + | fn into_response_unauthorized() { | |
| 289 | + | let (status, body, _) = response_status_and_body(AppError::Unauthorized); | |
| 290 | + | assert_eq!(status, StatusCode::UNAUTHORIZED); | |
| 291 | + | assert!(body.contains("log in")); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn into_response_forbidden() { | |
| 296 | + | let (status, body, _) = response_status_and_body(AppError::Forbidden); | |
| 297 | + | assert_eq!(status, StatusCode::FORBIDDEN); | |
| 298 | + | assert!(body.contains("permission")); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn into_response_bad_request() { | |
| 303 | + | let (status, body, _) = response_status_and_body(AppError::BadRequest("field required".into())); | |
| 304 | + | assert_eq!(status, StatusCode::BAD_REQUEST); | |
| 305 | + | assert_eq!(body, "field required"); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | #[test] | |
| 309 | + | fn into_response_validation() { | |
| 310 | + | let (status, body, _) = response_status_and_body(AppError::Validation("too long".into())); | |
| 311 | + | assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); | |
| 312 | + | assert_eq!(body, "too long"); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | #[test] | |
| 316 | + | fn into_response_invalid_file_type() { | |
| 317 | + | let (status, body, _) = response_status_and_body(AppError::InvalidFileType("not a PNG".into())); | |
| 318 | + | assert_eq!(status, StatusCode::BAD_REQUEST); | |
| 319 | + | assert_eq!(body, "not a PNG"); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | #[test] | |
| 323 | + | fn into_response_file_too_large() { | |
| 324 | + | let (status, body, _) = response_status_and_body(AppError::FileTooLarge("over 500 MB".into())); | |
| 325 | + | assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); | |
| 326 | + | assert_eq!(body, "over 500 MB"); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[test] | |
| 330 | + | fn into_response_malware_detected() { | |
| 331 | + | let (status, body, _) = response_status_and_body( | |
| 332 | + | AppError::MalwareDetected("ClamAV:Eicar-Signature".into()), | |
| 333 | + | ); | |
| 334 | + | assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); | |
| 335 | + | assert!(body.contains("security scanner")); | |
| 336 | + | assert!(!body.contains("ClamAV"), "internal scanner detail must not leak"); | |
| 337 | + | assert!(!body.contains("Eicar"), "internal signature name must not leak"); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn into_response_service_unavailable() { | |
| 342 | + | let (status, body, _) = response_status_and_body( | |
| 343 | + | AppError::ServiceUnavailable("try again in 5 minutes".into()), | |
| 344 | + | ); | |
| 345 | + | assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); | |
| 346 | + | assert_eq!(body, "try again in 5 minutes"); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // ── Internal detail leakage ───────────────────────────────────────── | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn into_response_internal_never_leaks_details() { | |
| 353 | + | let inner = anyhow::anyhow!("pg connection pool exhausted on host db-primary:5432"); | |
| 354 | + | let (status, body, _) = response_status_and_body(AppError::Internal(inner)); | |
| 355 | + | assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); | |
| 356 | + | assert!(body.contains("Something went wrong")); | |
| 357 | + | assert!(!body.contains("pg connection"), "internal detail must not leak"); | |
| 358 | + | assert!(!body.contains("5432"), "host/port must not leak"); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | #[test] | |
| 362 | + | fn into_response_database_never_leaks_details() { | |
| 363 | + | let err = AppError::Database(sqlx::Error::PoolTimedOut); | |
| 364 | + | let (status, body, _) = response_status_and_body(err); | |
| 365 | + | assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); | |
| 366 | + | assert!(body.contains("Something went wrong")); | |
| 367 | + | assert!(!body.contains("PoolTimedOut"), "sqlx variant must not leak"); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | #[test] | |
| 371 | + | fn into_response_storage_never_leaks_details() { | |
| 372 | + | let (status, body, _) = response_status_and_body( | |
| 373 | + | AppError::Storage("S3 PutObject failed: AccessDenied on bucket mnw-prod".into()), | |
| 374 | + | ); | |
| 375 | + | assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); | |
| 376 | + | assert!(body.contains("Something went wrong")); | |
| 377 | + | assert!(!body.contains("S3"), "S3 detail must not leak"); | |
| 378 | + | assert!(!body.contains("mnw-prod"), "bucket name must not leak"); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // ── ResultExt ─────────────────────────────────────────────────────── | |
| 382 | + | ||
| 383 | + | #[test] | |
| 384 | + | fn result_ext_context_wraps_error() { | |
| 385 | + | let original: std::result::Result<(), std::io::Error> = | |
| 386 | + | Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file missing")); | |
| 387 | + | let wrapped = original.context("loading config"); | |
| 388 | + | assert!(wrapped.is_err()); | |
| 389 | + | let app_err = wrapped.unwrap_err(); | |
| 390 | + | // Should produce an Internal variant | |
| 391 | + | assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR); | |
| 392 | + | // The context string should appear in the Debug representation | |
| 393 | + | let debug = format!("{:?}", app_err); | |
| 394 | + | assert!(debug.contains("loading config"), "context string should be in error chain"); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[test] | |
| 398 | + | fn result_ext_with_context_wraps_error() { | |
| 399 | + | let original: std::result::Result<(), std::io::Error> = | |
| 400 | + | Err(std::io::Error::new(std::io::ErrorKind::Other, "boom")); | |
| 401 | + | let wrapped = original.with_context(|| format!("processing item {}", 42)); | |
| 402 | + | assert!(wrapped.is_err()); | |
| 403 | + | let app_err = wrapped.unwrap_err(); | |
| 404 | + | assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR); | |
| 405 | + | let debug = format!("{:?}", app_err); | |
| 406 | + | assert!(debug.contains("processing item 42")); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | #[test] | |
| 410 | + | fn result_ext_ok_passes_through() { | |
| 411 | + | let original: std::result::Result<i32, std::io::Error> = Ok(99); | |
| 412 | + | let result = original.context("should not matter"); | |
| 413 | + | assert_eq!(result.unwrap(), 99); | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // ── Tag coverage ──────────────────────────────────────────────────── | |
| 417 | + | ||
| 418 | + | #[test] | |
| 419 | + | fn tag_matches_variant() { | |
| 420 | + | assert_eq!(AppError::NotFound.tag(), "not_found"); | |
| 421 | + | assert_eq!(AppError::Unauthorized.tag(), "unauthorized"); | |
| 422 | + | assert_eq!(AppError::Forbidden.tag(), "forbidden"); | |
| 423 | + | assert_eq!(AppError::BadRequest("x".into()).tag(), "bad_request"); | |
| 424 | + | assert_eq!(AppError::Validation("x".into()).tag(), "validation"); | |
| 425 | + | assert_eq!(AppError::Storage("x".into()).tag(), "storage"); | |
| 426 | + | assert_eq!(AppError::InvalidFileType("x".into()).tag(), "invalid_file_type"); | |
| 427 | + | assert_eq!(AppError::FileTooLarge("x".into()).tag(), "file_too_large"); | |
| 428 | + | assert_eq!(AppError::MalwareDetected("x".into()).tag(), "malware_detected"); | |
| 429 | + | assert_eq!(AppError::ServiceUnavailable("x".into()).tag(), "service_unavailable"); | |
| 430 | + | assert_eq!(AppError::Internal(anyhow::anyhow!("x")).tag(), "internal"); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // ── User message edge cases ───────────────────────────────────────── | |
| 434 | + | ||
| 435 | + | #[test] | |
| 436 | + | fn user_message_bad_request_preserves_content() { | |
| 437 | + | let msg = AppError::BadRequest("".into()).user_message(); | |
| 438 | + | assert_eq!(msg, ""); // empty input -> empty output (no crash) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | #[test] | |
| 442 | + | fn user_message_malware_detected_hides_detail() { | |
| 443 | + | let msg = AppError::MalwareDetected("Win.Trojan.Agent-123456".into()).user_message(); | |
| 444 | + | assert!(!msg.contains("Win.Trojan")); | |
| 445 | + | assert!(msg.contains("security scanner")); | |
| 446 | + | } | |
| 267 | 447 | } |
| @@ -129,7 +129,8 @@ | |||
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | 131 | /// Format a price in cents as a human-readable dollar string or "Free". | |
| 132 | - | pub fn format_price(cents: i32) -> String { | |
| 132 | + | pub fn format_price(cents: impl Into<i64>) -> String { | |
| 133 | + | let cents: i64 = cents.into(); | |
| 133 | 134 | if cents == 0 { | |
| 134 | 135 | "Free".to_string() | |
| 135 | 136 | } else if cents % 100 == 0 { |
| @@ -9,6 +9,7 @@ | |||
| 9 | 9 | pub mod email; | |
| 10 | 10 | pub mod error; | |
| 11 | 11 | pub mod git; | |
| 12 | + | pub mod git_ssh; | |
| 12 | 13 | pub mod license_templates; | |
| 13 | 14 | pub mod helpers; | |
| 14 | 15 | pub mod import; | |
| @@ -81,6 +82,9 @@ | |||
| 81 | 82 | pub wam: Option<wam_client::WamClient>, | |
| 82 | 83 | /// Cache of verified custom domains → user IDs (populated on startup, updated on verify/delete). | |
| 83 | 84 | pub domain_cache: Arc<DashMap<String, db::UserId>>, | |
| 85 | + | /// Limits concurrent file scans to prevent memory exhaustion (each scan | |
| 86 | + | /// downloads up to SCAN_MAX_MEMORY_BYTES into RAM). | |
| 87 | + | pub scan_semaphore: Arc<tokio::sync::Semaphore>, | |
| 84 | 88 | /// Unix timestamp when the server will restart (0 = no restart pending). | |
| 85 | 89 | /// Set by the deploy script via the internal API before uploading a new binary. | |
| 86 | 90 | pub restart_at: Arc<std::sync::atomic::AtomicI64>, | |
| @@ -182,7 +186,7 @@ | |||
| 182 | 186 | ); | |
| 183 | 187 | } | |
| 184 | 188 | ||
| 185 | - | app.layer(middleware::from_fn(security_headers_middleware)) | |
| 189 | + | app.layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware)) | |
| 186 | 190 | .layer(middleware::from_fn(metrics::cache_control_middleware)) | |
| 187 | 191 | .layer(middleware::from_fn(metrics::metrics_middleware)) | |
| 188 | 192 | .layer(middleware::from_fn(csrf::csrf_middleware)) | |
| @@ -194,6 +198,7 @@ | |||
| 194 | 198 | /// Middleware that sets security headers on all responses. | |
| 195 | 199 | /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding. | |
| 196 | 200 | async fn security_headers_middleware( | |
| 201 | + | axum::extract::State(state): axum::extract::State<AppState>, | |
| 197 | 202 | request: axum::http::Request<axum::body::Body>, | |
| 198 | 203 | next: middleware::Next, | |
| 199 | 204 | ) -> axum::response::Response { | |
| @@ -217,12 +222,28 @@ | |||
| 217 | 222 | axum::http::header::X_FRAME_OPTIONS, | |
| 218 | 223 | HeaderValue::from_static("DENY"), | |
| 219 | 224 | ); | |
| 220 | - | headers.insert( | |
| 221 | - | axum::http::header::HeaderName::from_static("content-security-policy"), | |
| 222 | - | HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self' https://cdn.makenot.work; frame-ancestors 'none'"), | |
| 225 | + | // Build CSP with the configured CDN domain for media-src | |
| 226 | + | let media_src = match state.config.cdn_base_url.as_deref() { | |
| 227 | + | Some(cdn) => format!("media-src 'self' {cdn}"), | |
| 228 | + | None => "media-src 'self'".to_string(), | |
| 229 | + | }; | |
| 230 | + | let csp = format!( | |
| 231 | + | "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \ | |
| 232 | + | img-src 'self' data: https:; font-src 'self'; connect-src 'self'; \ | |
| 233 | + | {media_src}; frame-ancestors 'none'" | |
| 223 | 234 | ); | |
| 235 | + | if let Ok(value) = HeaderValue::from_str(&csp) { | |
| 236 | + | headers.insert( | |
| 237 | + | axum::http::header::HeaderName::from_static("content-security-policy"), | |
| 238 | + | value, | |
| 239 | + | ); | |
| 240 | + | } | |
| 224 | 241 | } | |
| 225 | 242 | ||
| 243 | + | headers.insert( | |
| 244 | + | axum::http::header::HeaderName::from_static("strict-transport-security"), | |
| 245 | + | HeaderValue::from_static("max-age=31536000; includeSubDomains"), | |
| 246 | + | ); | |
| 226 | 247 | headers.insert( | |
| 227 | 248 | axum::http::header::X_CONTENT_TYPE_OPTIONS, | |
| 228 | 249 | HeaderValue::from_static("nosniff"), |
| @@ -299,4 +299,191 @@ | |||
| 299 | 299 | fn invalid_preset_parse_fails() { | |
| 300 | 300 | assert!("nonexistent".parse::<LicensePreset>().is_err()); | |
| 301 | 301 | } | |
| 302 | + | ||
| 303 | + | // ── All template variants render without leftover placeholders ── | |
| 304 | + | ||
| 305 | + | #[test] | |
| 306 | + | fn render_all_presets_no_leftover_placeholders() { | |
| 307 | + | for preset in ALL_PRESETS { | |
| 308 | + | if *preset == LicensePreset::Custom { | |
| 309 | + | continue; | |
| 310 | + | } | |
| 311 | + | let text = render_license_text(*preset, "SomeOwner", 2025, None); | |
| 312 | + | assert!( | |
| 313 | + | !text.contains("{year}"), | |
| 314 | + | "{:?} still contains {{year}}", | |
| 315 | + | preset | |
| 316 | + | ); | |
| 317 | + | assert!( | |
| 318 | + | !text.contains("{owner}"), | |
| 319 | + | "{:?} still contains {{owner}}", | |
| 320 | + | preset | |
| 321 | + | ); | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[test] | |
| 326 | + | fn render_all_presets_contain_year() { | |
| 327 | + | for preset in ALL_PRESETS { | |
| 328 | + | if *preset == LicensePreset::Custom || *preset == LicensePreset::Cc0 { | |
| 329 | + | continue; // CC0 template has no {year} placeholder | |
| 330 | + | } | |
| 331 | + | let text = render_license_text(*preset, "Owner", 2026, None); | |
| 332 | + | assert!( | |
| 333 | + | text.contains("2026"), | |
| 334 | + | "{:?} should contain the year", | |
| 335 | + | preset | |
| 336 | + | ); | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn render_personal_use_contains_non_commercial() { | |
| 342 | + | let text = render_license_text(LicensePreset::PersonalUse, "Owner", 2026, None); | |
| 343 | + | assert!(text.contains("non-commercial")); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn render_royalty_free_contains_perpetual() { | |
| 348 | + | let text = render_license_text(LicensePreset::RoyaltyFree, "Owner", 2026, None); | |
| 349 | + | assert!(text.contains("perpetual")); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[test] | |
| 353 | + | fn render_mit_contains_permission_notice() { | |
| 354 | + | let text = render_license_text(LicensePreset::Mit, "Owner", 2026, None); | |
| 355 | + | assert!(text.contains("Permission is hereby granted")); | |
| 356 | + | assert!(text.contains("AS IS")); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn render_apache2_contains_license_url() { | |
| 361 | + | let text = render_license_text(LicensePreset::Apache2, "Owner", 2026, None); | |
| 362 | + | assert!(text.contains("http://www.apache.org/licenses/LICENSE-2.0")); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn render_cc_by_4_contains_attribution() { | |
| 367 | + | let text = render_license_text(LicensePreset::CcBy4, "Owner", 2026, None); | |
| 368 | + | assert!(text.contains("Attribution")); | |
| 369 | + | assert!(text.contains("creativecommons.org")); | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn render_cc_by_nc_4_contains_noncommercial() { | |
| 374 | + | let text = render_license_text(LicensePreset::CcByNc4, "Owner", 2026, None); | |
| 375 | + | assert!(text.contains("NonCommercial")); | |
| 376 | + | assert!(text.contains("creativecommons.org")); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | #[test] | |
| 380 | + | fn render_cc0_contains_public_domain() { | |
| 381 | + | let text = render_license_text(LicensePreset::Cc0, "Owner", 2026, None); | |
| 382 | + | assert!(text.contains("public domain")); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // ── Special characters in variable values ── | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn render_owner_with_special_characters() { | |
| 389 | + | let text = render_license_text(LicensePreset::Mit, "O'Brien & Co. <LLC>", 2026, None); | |
| 390 | + | assert!(text.contains("O'Brien & Co. <LLC>")); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | #[test] | |
| 394 | + | fn render_owner_with_unicode() { | |
| 395 | + | let text = render_license_text(LicensePreset::Mit, "Müller GmbH", 2026, None); | |
| 396 | + | assert!(text.contains("Müller GmbH")); | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | #[test] | |
| 400 | + | fn render_owner_with_curly_braces() { | |
| 401 | + | // Ensure literal braces in owner name don't break substitution | |
| 402 | + | let text = render_license_text(LicensePreset::PersonalUse, "{braces}", 2026, None); | |
| 403 | + | assert!(text.contains("{braces}")); | |
| 404 | + | assert!(!text.contains("{year}")); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | #[test] | |
| 408 | + | fn render_empty_owner() { | |
| 409 | + | let text = render_license_text(LicensePreset::Mit, "", 2026, None); | |
| 410 | + | assert!(text.contains("Copyright (c) 2026 ")); | |
| 411 | + | assert!(!text.contains("{owner}")); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // ── Custom template edge cases ── | |
| 415 | + | ||
| 416 | + | #[test] | |
| 417 | + | fn render_custom_ignores_owner_and_year() { | |
| 418 | + | let text = render_license_text( | |
| 419 | + | LicensePreset::Custom, | |
| 420 | + | "Ignored Owner", | |
| 421 | + | 9999, | |
| 422 | + | Some("No substitution happens for {year} or {owner}."), | |
| 423 | + | ); | |
| 424 | + | // Custom text is returned as-is, no substitution | |
| 425 | + | assert!(text.contains("{year}")); | |
| 426 | + | assert!(text.contains("{owner}")); | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | #[test] | |
| 430 | + | fn render_custom_with_empty_string() { | |
| 431 | + | let text = render_license_text(LicensePreset::Custom, "Owner", 2026, Some("")); | |
| 432 | + | assert_eq!(text, ""); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | #[test] | |
| 436 | + | fn render_custom_with_multiline_text() { | |
| 437 | + | let custom = "Line 1\nLine 2\n\nLine 4"; | |
| 438 | + | let text = render_license_text(LicensePreset::Custom, "Owner", 2026, Some(custom)); | |
| 439 | + | assert_eq!(text, custom); | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // ── Display / FromStr edge cases ── | |
| 443 | + | ||
| 444 | + | #[test] | |
| 445 | + | fn display_matches_as_str() { | |
| 446 | + | for preset in ALL_PRESETS { | |
| 447 | + | assert_eq!(format!("{preset}"), preset.as_str()); | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | #[test] | |
| 452 | + | fn from_str_case_sensitive() { | |
| 453 | + | // Uppercase should fail | |
| 454 | + | assert!("MIT".parse::<LicensePreset>().is_err()); | |
| 455 | + | assert!("Personal_Use".parse::<LicensePreset>().is_err()); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | #[test] | |
| 459 | + | fn from_str_empty_string_fails() { | |
| 460 | + | assert!("".parse::<LicensePreset>().is_err()); | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | #[test] | |
| 464 | + | fn preset_options_values_match_as_str() { | |
| 465 | + | let opts = preset_options(); | |
| 466 | + | for (i, preset) in ALL_PRESETS.iter().enumerate() { | |
| 467 | + | assert_eq!(opts[i].0, preset.as_str()); | |
| 468 | + | assert_eq!(opts[i].1, preset.label()); | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | #[test] | |
| 473 | + | fn all_presets_have_unique_keys() { | |
| 474 | + | let keys: Vec<&str> = ALL_PRESETS.iter().map(|p| p.as_str()).collect(); | |
| 475 | + | let mut deduped = keys.clone(); | |
| 476 | + | deduped.sort(); | |
| 477 | + | deduped.dedup(); | |
| 478 | + | assert_eq!(keys.len(), deduped.len()); | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | #[test] | |
| 482 | + | fn all_presets_have_unique_labels() { | |
| 483 | + | let labels: Vec<&str> = ALL_PRESETS.iter().map(|p| p.label()).collect(); | |
| 484 | + | let mut deduped = labels.clone(); | |
| 485 | + | deduped.sort(); | |
| 486 | + | deduped.dedup(); | |
| 487 | + | assert_eq!(labels.len(), deduped.len()); | |
| 488 | + | } | |
| 302 | 489 | } |
| @@ -280,12 +280,25 @@ | |||
| 280 | 280 | mt_client, | |
| 281 | 281 | wam, | |
| 282 | 282 | domain_cache, | |
| 283 | + | scan_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::SCAN_MAX_CONCURRENT)), | |
| 283 | 284 | restart_at: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), | |
| 284 | 285 | sync_notify: std::sync::Arc::new(dashmap::DashMap::new()), | |
| 285 | 286 | sse_connections: std::sync::Arc::new(dashmap::DashMap::new()), | |
| 286 | 287 | metrics_handle: Some(makenotwork::metrics::init()), | |
| 287 | 288 | }; | |
| 288 | 289 | ||
| 290 | + | // Log active features at startup | |
| 291 | + | tracing::info!( | |
| 292 | + | s3 = state.s3.is_some(), | |
| 293 | + | synckit_s3 = state.synckit_s3.is_some(), | |
| 294 | + | stripe = state.stripe.is_some(), | |
| 295 | + | scanner = state.scanner.is_some(), | |
| 296 | + | mt = state.mt_client.is_some(), | |
| 297 | + | wam = state.wam.is_some(), | |
| 298 | + | git = state.config.git_repos_path.is_some(), | |
| 299 | + | "Active features" | |
| 300 | + | ); | |
| 301 | + | ||
| 289 | 302 | // Start background health monitor and scheduler | |
| 290 | 303 | let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(()); | |
| 291 | 304 | let _monitor_handle = makenotwork::monitor::spawn_monitor(state.clone(), shutdown_rx); | |
| @@ -380,7 +393,7 @@ | |||
| 380 | 393 | // Spawn a hard deadline: if graceful drain takes longer than 10s, force exit | |
| 381 | 394 | tokio::spawn(async { | |
| 382 | 395 | tokio::time::sleep(Duration::from_secs(10)).await; | |
| 383 | - | tracing::warn!("Graceful shutdown timed out after 10s, forcing exit"); | |
| 396 | + | eprintln!("Graceful shutdown timed out after 10s, forcing exit"); | |
| 384 | 397 | std::process::exit(1); | |
| 385 | 398 | }); | |
| 386 | 399 | } |