max / makenotwork
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 | - | } |