Skip to main content

max / makenotwork

server: quality cleanups (wordlist self-test, duration clamp, typed member paths, collections DOM) - wordlist.rs: add uniqueness / size / lowercase-ascii self-tests backing the ~66-bit entropy claim (no duplicates found). - format_duration_ms: clamp negatives to "0:00" instead of a malformed "0:-1". - projects.rs: add/remove_project_member now use typed Path<ProjectId> / Path<(ProjectId, UserId)> extractors instead of hand-parsing Path<String>, matching the rest of the file. - collections.js: build the collection-picker rows via DOM construction and an addEventListener handler instead of interpolating ids into an inline onchange attribute; drops the now-dead collectionEscapeHtml helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:11 UTC
Commit: 896ba437cdcbd0b99121c272d95bf771d781a908
Parent: a135b29
4 files changed, +68 insertions, -31 deletions
@@ -482,7 +482,10 @@ fn clip_compatible_with_item(clip_media_type: &str, item_type: db::ItemType) ->
482 482
483 483 /// Format milliseconds as MM:SS.
484 484 fn format_duration_ms(ms: i32) -> String {
485 - let total_secs = ms / 1000;
485 + // Clamp negatives to zero: duration_ms is validated > 0 at confirm time, so
486 + // this is unreachable in practice, but a stray negative previously produced
487 + // a malformed "0:-1" rather than a sane "0:00".
488 + let total_secs = ms.max(0) / 1000;
486 489 let mins = total_secs / 60;
487 490 let secs = total_secs % 60;
488 491 format!("{}:{:02}", mins, secs)
@@ -528,10 +531,11 @@ mod tests {
528 531 }
529 532
530 533 #[test]
531 - fn negative_ms_rounds_toward_zero() {
532 - // Rust integer division truncates toward zero:
533 - // -1500 / 1000 = -1, -1 / 60 = 0, -1 % 60 = -1
534 - assert_eq!(format_duration_ms(-1500), "0:-1");
534 + fn negative_ms_clamps_to_zero() {
535 + // Defensive clamp: a negative duration (unreachable — validated > 0 at
536 + // confirm) formats as "0:00", not a malformed "0:-1".
537 + assert_eq!(format_duration_ms(-1500), "0:00");
538 + assert_eq!(format_duration_ms(-1), "0:00");
535 539 }
536 540
537 541 #[test]
@@ -604,13 +604,9 @@ pub struct AddMemberForm {
604 604 pub async fn add_project_member(
605 605 State(state): State<AppState>,
606 606 AuthUser(session_user): AuthUser,
607 - Path(project_id): Path<String>,
607 + Path(project_id): Path<ProjectId>,
608 608 Form(form): Form<AddMemberForm>,
609 609 ) -> Result<Response> {
610 - let project_id: ProjectId = project_id.parse::<uuid::Uuid>()
611 - .map(ProjectId::from)
612 - .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
613 -
614 610 let _project = verify_project_ownership(&state, project_id, session_user.id).await?;
615 611
616 612 // Validate split percent
@@ -654,16 +650,8 @@ pub async fn add_project_member(
654 650 pub async fn remove_project_member(
655 651 State(state): State<AppState>,
656 652 AuthUser(session_user): AuthUser,
657 - Path((project_id, user_id)): Path<(String, String)>,
653 + Path((project_id, user_id)): Path<(ProjectId, db::UserId)>,
658 654 ) -> Result<Response> {
659 - let project_id: ProjectId = project_id.parse::<uuid::Uuid>()
660 - .map(ProjectId::from)
661 - .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
662 -
663 - let user_id: db::UserId = user_id.parse::<uuid::Uuid>()
664 - .map(db::UserId::from)
665 - .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;
666 -
667 655 verify_project_ownership(&state, project_id, session_user.id).await?;
668 656
669 657 let removed = db::project_members::remove_project_member(&state.db, project_id, user_id).await?;
@@ -2055,3 +2055,42 @@ pub static WORDLIST: [&str; 2048] = [
2055 2055 "lemur",
2056 2056 "pixie",
2057 2057 ];
2058 +
2059 + #[cfg(test)]
2060 + mod tests {
2061 + use super::WORDLIST;
2062 + use std::collections::HashSet;
2063 +
2064 + /// The ~66-bit entropy claim (6 words from 2048 => 2048^6 == 2^66) holds
2065 + /// only if the words are distinct: a duplicate shrinks the effective pool
2066 + /// and biases key generation. Pin uniqueness so an accidental copy-paste
2067 + /// during a future edit fails the build rather than silently weakening keys.
2068 + #[test]
2069 + fn wordlist_entries_are_unique() {
2070 + let mut seen = HashSet::with_capacity(WORDLIST.len());
2071 + for w in WORDLIST {
2072 + assert!(seen.insert(w), "duplicate word in WORDLIST: {w:?}");
2073 + }
2074 + assert_eq!(seen.len(), WORDLIST.len());
2075 + }
2076 +
2077 + /// Guard the pool size the entropy math depends on. A power of two also
2078 + /// keeps index selection unbiased (2048 divides the u16 space evenly).
2079 + #[test]
2080 + fn wordlist_is_exactly_2048_words() {
2081 + assert_eq!(WORDLIST.len(), 2048);
2082 + }
2083 +
2084 + /// Words must be lowercase ASCII with no whitespace/punctuation so keys are
2085 + /// unambiguous to read back and type. Also rejects the empty string.
2086 + #[test]
2087 + fn wordlist_entries_are_lowercase_ascii() {
2088 + for w in WORDLIST {
2089 + assert!(!w.is_empty(), "empty word in WORDLIST");
2090 + assert!(
2091 + w.chars().all(|c| c.is_ascii_lowercase()),
2092 + "non-lowercase-ascii word in WORDLIST: {w:?}"
2093 + );
2094 + }
2095 + }
2096 + }
@@ -83,15 +83,27 @@ function collectionPickerLoad(itemId, picker) {
83 83 list.innerHTML = '<div class="empty-state empty-state--compact">No collections yet. Create one below.</div>';
84 84 return;
85 85 }
86 - var html = '';
86 + // Build the rows via DOM construction rather than interpolating
87 + // c.id / itemId / c.title into an HTML string. Nothing is parsed as
88 + // HTML, so there is no escaping to get wrong and no attribute the
89 + // ids could break out of.
90 + list.innerHTML = '';
87 91 for (var i = 0; i < cols.length; i++) {
88 92 var c = cols[i];
89 - html += '<label class="collection-picker-item">'
90 - + '<input type="checkbox"' + (c.in_collection ? ' checked' : '')
91 - + ' onchange="collectionPickerToggle(\'' + c.id + '\',\'' + itemId + '\',this.checked)">'
92 - + collectionEscapeHtml(c.title) + '</label>';
93 + var label = document.createElement('label');
94 + label.className = 'collection-picker-item';
95 + var cb = document.createElement('input');
96 + cb.type = 'checkbox';
97 + cb.checked = !!c.in_collection;
98 + (function(collectionId) {
99 + cb.addEventListener('change', function() {
100 + collectionPickerToggle(collectionId, itemId, this.checked);
101 + });
102 + })(c.id);
103 + label.appendChild(cb);
104 + label.appendChild(document.createTextNode(c.title));
105 + list.appendChild(label);
93 106 }
94 - list.innerHTML = html;
95 107 })
96 108 .catch(function() {
97 109 list.innerHTML = '<div class="empty-state empty-state--compact" style="color:var(--danger)">Failed to load collections.</div>';
@@ -178,9 +190,3 @@ function collectionPickerUpdateButtons(itemId) {
178 190 }
179 191 });
180 192 }
181 -
182 - function collectionEscapeHtml(s) {
183 - var d = document.createElement('div');
184 - d.textContent = s;
185 - return d.innerHTML;
186 - }