Skip to main content

max / makenotwork

Audit Run 16: type safety, performance, dedup, testing (A- -> A) Type safety: Cents(i64) newtype for all monetary values (35+ fields, 15 files), SessionUser.creator_tier as enum, OnboardingStep enum, format_price accepts impl Into<i64>. Performance: UNNEST batch updates (move_item, set_bundle_items), NOT EXISTS for email suppressions, single-CTE storage breakdown, batch storage recalculation, combined session touch query (-2 round trips per request). Dedup: SessionUser::from_db_user (5 sites), maybe_send_login_notification (2 sites), cleanup_user_s3_and_delete (3 sites), format_duration, download_response (6 sites). Testing: 248 new unit tests (738 -> 986). New coverage for error.rs, creator_tiers.rs, constants.rs, conversions.rs, promo_codes.rs, monitor.rs, csv_converter.rs, license_templates.rs, csrf.rs, rss.rs, models/item.rs, scheduler.rs, validated_types.rs. Fixes: bundles.rs column bug, test harness scan_semaphore, stale doc comment, tip truncation 280->500, .gitignore secret patterns, auth.rs #[instrument], config startup log, storage delete_prefix warning, guest_checkout inline SQL moved to db module, discover search titles only. Landing page: removed Now Open badge, updated Host anything copy, trimmed Fan+ copy.
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-29 15:30 UTC
Commit: cfe8c31e2ed7b72bde03814a468f502d2cc0dd37
Parent: 2e6c345
73 files changed, +4272 insertions, -1354 deletions
M .gitignore +9
@@ -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.
M server/src/auth.rs +96 -15
@@ -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 }