Skip to main content

max / makenotwork

server: close Architecture B+ dups + rss control-byte gap (audit Run 17) Offline-verifiable cold spots from the Run 17 audit: - rss.rs xml_escape now drops XML-1.0-illegal C0 control bytes (all but tab/LF/CR), which are illegal even escaped and make a strict parser reject the whole feed. Defense-in-depth over upstream validation; unit test added. - project_tabs.rs: extract load_project_members() — the member-row mapping + split fetch were copy-pasted verbatim in the members tab and the monetization tab. - discover.rs: extract DiscoverQuery::filter_selection() — the empty->None + enum-parse filter logic was derived independently in fetch_discover_data and the discover handler; now one method, destructured into the same locals at both sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 17:56 UTC
Commit: 6753ee96b845652d06b1c84fc5f65a296f4c714f
Parent: ef207aa
3 files changed, +97 insertions, -56 deletions
@@ -522,18 +522,14 @@ pub(super) async fn project_tab_code(
522 522
523 523 /// Render the HTMX partial for the project members tab.
524 524 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")]
525 - pub(super) async fn project_tab_members(
526 - State(state): State<AppState>,
527 - AuthUser(session_user): AuthUser,
528 - headers: HeaderMap,
529 - Path(slug): Path<String>,
530 - ) -> Result<axum::response::Response> {
531 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
532 - Ok(pair) => pair,
533 - Err(not_modified) => return Ok(not_modified),
534 - };
535 -
536 - let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?;
525 + /// Load a project's members as view rows plus the owner's residual split
526 + /// percent. Shared by the members tab and the combined monetization tab — the
527 + /// mapping was copy-pasted verbatim in both (audit Run 17 Architecture).
528 + async fn load_project_members(
529 + state: &AppState,
530 + project_id: db::ProjectId,
531 + ) -> Result<(Vec<ProjectMemberRow>, i64)> {
532 + let db_members = db::project_members::get_project_members(&state.db, project_id).await?;
537 533 let members: Vec<ProjectMemberRow> = db_members
538 534 .iter()
539 535 .map(|m| ProjectMemberRow {
@@ -547,9 +543,22 @@ pub(super) async fn project_tab_members(
547 543 added_at: m.added_at.format("%Y-%m-%d").to_string(),
548 544 })
549 545 .collect();
546 + let total_member_split = db::project_members::get_total_split_percent(&state.db, project_id).await?;
547 + Ok((members, 100 - total_member_split))
548 + }
550 549
551 - let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?;
552 - let owner_split = 100 - total_member_split;
550 + pub(super) async fn project_tab_members(
551 + State(state): State<AppState>,
552 + AuthUser(session_user): AuthUser,
553 + headers: HeaderMap,
554 + Path(slug): Path<String>,
555 + ) -> Result<axum::response::Response> {
556 + let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
557 + Ok(pair) => pair,
558 + Err(not_modified) => return Ok(not_modified),
559 + };
560 +
561 + let (members, owner_split) = load_project_members(&state, db_project.id).await?;
553 562
554 563 Ok(helpers::with_etag(generation, ProjectMembersTabTemplate {
555 564 project_id: db_project.id.to_string(),
@@ -591,22 +600,7 @@ pub(super) async fn project_tab_monetization(
591 600 .collect();
592 601
593 602 // Members
594 - let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?;
595 - let members: Vec<ProjectMemberRow> = db_members
596 - .iter()
597 - .map(|m| ProjectMemberRow {
598 - id: m.id.to_string(),
599 - user_id: m.user_id.to_string(),
600 - username: m.username.clone(),
601 - display_name: m.display_name.clone(),
602 - role: m.role.to_string(),
603 - split_percent: m.split_percent,
604 - stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,
605 - added_at: m.added_at.format("%Y-%m-%d").to_string(),
606 - })
607 - .collect();
608 - let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?;
609 - let owner_split = 100 - total_member_split;
603 + let (members, owner_split) = load_project_members(&state, db_project.id).await?;
610 604
611 605 Ok(helpers::with_etag(generation, ProjectMonetizationTabTemplate {
612 606 project_id: db_project.id.to_string(),
@@ -178,6 +178,32 @@ mod price_range_tests {
178 178 }
179 179
180 180 /// Fetch items or projects with pagination; shared by both handlers.
181 + /// The normalized filter selection parsed from a `DiscoverQuery`: empty strings
182 + /// collapse to `None` and enum-valued params are parsed. Both the data fetch and
183 + /// the filter-chip rendering need exactly this and derived it independently
184 + /// (audit Run 17 Architecture) — the logic now lives in one place.
185 + struct DiscoverFilterSelection<'a> {
186 + item_type: Option<ItemType>,
187 + tag: Option<&'a str>,
188 + search: Option<&'a str>,
189 + category: Option<&'a str>,
190 + ai_tier: Option<db::AiTierFilter>,
191 + has_source_code: bool,
192 + }
193 +
194 + impl DiscoverQuery {
195 + fn filter_selection(&self) -> DiscoverFilterSelection<'_> {
196 + DiscoverFilterSelection {
197 + item_type: self.item_type.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()),
198 + tag: self.tag.as_deref().filter(|s| !s.is_empty()),
199 + search: self.q.as_deref().filter(|s| !s.trim().is_empty()),
200 + category: self.category.as_deref().filter(|s| !s.is_empty()),
201 + ai_tier: self.ai_tier.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()),
202 + has_source_code: self.has_source.as_deref() == Some("1"),
203 + }
204 + }
205 + }
206 +
181 207 async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> {
182 208 // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a
183 209 // giant OFFSET = one expensive deep scan per request. Matches the git/admin
@@ -187,19 +213,13 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
187 213 let offset = ((page - 1) as i64) * limit;
188 214 let mode = query.mode.as_deref().unwrap_or("projects");
189 215
190 - let item_type_filter: Option<ItemType> = query.item_type.as_deref()
191 - .filter(|s| !s.is_empty())
192 - .and_then(|s| s.parse().ok());
193 - let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty());
194 - let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty());
195 -
196 - let category_filter = query.category.as_deref().filter(|s| !s.is_empty());
197 -
198 - let ai_tier_filter: Option<db::AiTierFilter> = query.ai_tier.as_deref()
199 - .filter(|s| !s.is_empty())
200 - .and_then(|s| s.parse().ok());
201 -
202 - let has_source_code = query.has_source.as_deref() == Some("1");
216 + let f = query.filter_selection();
217 + let item_type_filter = f.item_type;
218 + let tag_filter = f.tag;
219 + let search_filter = f.search;
220 + let category_filter = f.category;
221 + let ai_tier_filter = f.ai_tier;
222 + let has_source_code = f.has_source_code;
203 223
204 224 let (items, projects, total_count) = if mode == "projects" {
205 225 let sort_filter: Option<DiscoverSort> = query.sort.as_deref()
@@ -367,16 +387,15 @@ pub(super) async fn discover(
367 387 Query(query): Query<DiscoverQuery>,
368 388 ) -> Result<impl IntoResponse> {
369 389 let csrf_token = get_csrf_token(&session).await;
370 - let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty());
371 - let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty());
372 - let item_type_filter: Option<ItemType> = query.item_type.as_deref()
373 - .filter(|s| !s.is_empty())
374 - .and_then(|s| s.parse().ok());
375 - let has_source_code = query.has_source.as_deref() == Some("1");
390 + let f = query.filter_selection();
391 + let search_filter = f.search;
392 + let tag_filter = f.tag;
393 + let item_type_filter = f.item_type;
394 + let has_source_code = f.has_source_code;
376 395 let data = fetch_discover_data(&state.db, &query).await?;
377 396
378 397 // Build type and tag filters (items mode only)
379 - let category_filter = query.category.as_deref().filter(|s| !s.is_empty());
398 + let category_filter = f.category;
380 399
381 400 // Build category filters (projects mode only)
382 401 let category_filters = if data.mode == "projects" {
@@ -16,13 +16,30 @@ pub struct FeedItem {
16 16 pub guid: String,
17 17 }
18 18
19 - /// Escape XML special characters.
19 + /// Escape XML special characters, and drop bytes XML 1.0 forbids outright.
20 + ///
21 + /// XML 1.0 permits only `#x9` (tab), `#xA` (LF), and `#xD` (CR) from the C0
22 + /// control range; every other control byte (`#x0`-`#x8`, `#xB`, `#xC`,
23 + /// `#xE`-`#x1F`) is illegal *even when escaped* — a strict feed parser rejects
24 + /// the whole document. Upstream validation already blocks most of these, so this
25 + /// is defense-in-depth: silently drop them (there is no valid representation) so
26 + /// a stray control byte in a title/description can't produce an unparseable feed.
20 27 fn xml_escape(s: &str) -> String {
21 - s.replace('&', "&amp;")
22 - .replace('<', "&lt;")
23 - .replace('>', "&gt;")
24 - .replace('"', "&quot;")
25 - .replace('\'', "&apos;")
28 + let mut out = String::with_capacity(s.len());
29 + for c in s.chars() {
30 + match c {
31 + '&' => out.push_str("&amp;"),
32 + '<' => out.push_str("&lt;"),
33 + '>' => out.push_str("&gt;"),
34 + '"' => out.push_str("&quot;"),
35 + '\'' => out.push_str("&apos;"),
36 + '\t' | '\n' | '\r' => out.push(c),
37 + // C0 control byte that XML 1.0 forbids — drop it.
38 + c if (c as u32) < 0x20 => {}
39 + c => out.push(c),
40 + }
41 + }
42 + out
26 43 }
27 44
28 45 /// Core RSS 2.0 renderer. All public feed functions delegate here.
@@ -270,6 +287,17 @@ mod tests {
270 287 }
271 288
272 289 #[test]
290 + fn xml_escape_drops_illegal_control_bytes_keeps_tab_lf_cr() {
291 + // NUL, backspace, vertical tab, form feed, and other C0 controls are
292 + // illegal in XML 1.0 even escaped — they must be dropped, not emitted.
293 + assert_eq!(xml_escape("a\u{0}b\u{8}c\u{B}d\u{C}e\u{1F}f"), "abcdef");
294 + // Tab / LF / CR are the three legal control chars — preserved verbatim.
295 + assert_eq!(xml_escape("x\ty\nz\r"), "x\ty\nz\r");
296 + // A control byte adjacent to an entity char still escapes correctly.
297 + assert_eq!(xml_escape("A\u{0}&B"), "A&amp;B");
298 + }
299 +
300 + #[test]
273 301 fn pub_date_is_rfc2822() {
274 302 let date = Utc.with_ymd_and_hms(2026, 3, 15, 14, 30, 0).unwrap();
275 303 let item = FeedItem {