max / balanced_breakfast
6 files changed,
+83 insertions,
-13 deletions
| @@ -968,7 +968,7 @@ mod tests { | |||
| 968 | 968 | "source_name".into(), | |
| 969 | 969 | Dynamic::from("Test Source".to_string()), | |
| 970 | 970 | ); | |
| 971 | - | meta_map.insert("published_at".into(), Dynamic::from(1700000000_i64)); | |
| 971 | + | meta_map.insert("published_at".into(), Dynamic::from(1_700_000_000_i64)); | |
| 972 | 972 | meta_map.insert("score".into(), Dynamic::from(42_i64)); | |
| 973 | 973 | ||
| 974 | 974 | let tags: rhai::Array = vec![ | |
| @@ -983,7 +983,7 @@ mod tests { | |||
| 983 | 983 | ||
| 984 | 984 | let item = dynamic_to_feed_item(Dynamic::from(map), "src").unwrap(); | |
| 985 | 985 | assert_eq!(item.meta.source_name, "Test Source"); | |
| 986 | - | assert_eq!(item.meta.published_at, 1700000000); | |
| 986 | + | assert_eq!(item.meta.published_at, 1_700_000_000); | |
| 987 | 987 | assert_eq!(item.meta.score, Some(42)); | |
| 988 | 988 | assert_eq!(item.meta.tags, vec!["rust", "tech"]); | |
| 989 | 989 | } |
| @@ -239,7 +239,7 @@ mod tests { | |||
| 239 | 239 | #[test] | |
| 240 | 240 | fn busser_id_display() { | |
| 241 | 241 | let id = BusserId::new("hn"); | |
| 242 | - | assert_eq!(format!("{}", id), "hn"); | |
| 242 | + | assert_eq!(format!("{id}"), "hn"); | |
| 243 | 243 | } | |
| 244 | 244 | ||
| 245 | 245 | #[test] |
| @@ -1476,7 +1476,7 @@ async fn circuit_breaker_trips_at_threshold() { | |||
| 1476 | 1476 | // Record failures up to threshold - 1: should not trip | |
| 1477 | 1477 | for i in 0..(CIRCUIT_BREAKER_THRESHOLD - 1) { | |
| 1478 | 1478 | let tripped = feeds | |
| 1479 | - | .record_fetch_failure(feed.id, &format!("error {}", i)) | |
| 1479 | + | .record_fetch_failure(feed.id, &format!("error {i}")) | |
| 1480 | 1480 | .await | |
| 1481 | 1481 | .unwrap(); | |
| 1482 | 1482 | assert!(!tripped, "should not trip at failure {}", i + 1); |
| @@ -340,7 +340,7 @@ mod tests { | |||
| 340 | 340 | fn make_item(source: &str, id: &str, published_at: i64) -> FeedItem { | |
| 341 | 341 | FeedItem::new( | |
| 342 | 342 | FeedItemId::new(source, id), | |
| 343 | - | BiteDisplay::new("author", format!("text for {}", id)), | |
| 343 | + | BiteDisplay::new("author", format!("text for {id}")), | |
| 344 | 344 | FeedItemContent::new(), | |
| 345 | 345 | FeedItemMeta::new(source, published_at), | |
| 346 | 346 | ) |
| @@ -319,6 +319,20 @@ fn wrap(text: &str, style: Style, max_width_mm: f32) -> Vec<String> { | |||
| 319 | 319 | let mut lines: Vec<String> = Vec::new(); | |
| 320 | 320 | let mut cur = String::new(); | |
| 321 | 321 | for word in text.split_whitespace() { | |
| 322 | + | // A single token wider than the column can never fit on any line; break it | |
| 323 | + | // on character boundaries so it wraps instead of running off the page edge. | |
| 324 | + | if text_width_mm(word, style.size_pt, style.bold, style.mono) > max_width_mm { | |
| 325 | + | if !cur.is_empty() { | |
| 326 | + | lines.push(std::mem::take(&mut cur)); | |
| 327 | + | } | |
| 328 | + | let mut pieces = hard_break(word, style, max_width_mm); | |
| 329 | + | // Keep the trailing piece in `cur` so the next word can share its line. | |
| 330 | + | if let Some(last) = pieces.pop() { | |
| 331 | + | lines.append(&mut pieces); | |
| 332 | + | cur = last; | |
| 333 | + | } | |
| 334 | + | continue; | |
| 335 | + | } | |
| 322 | 336 | let candidate = if cur.is_empty() { | |
| 323 | 337 | word.to_string() | |
| 324 | 338 | } else { | |
| @@ -342,6 +356,28 @@ fn wrap(text: &str, style: Style, max_width_mm: f32) -> Vec<String> { | |||
| 342 | 356 | lines | |
| 343 | 357 | } | |
| 344 | 358 | ||
| 359 | + | /// Break a single token wider than the column into character-boundary pieces that | |
| 360 | + | /// each fit within `max_width_mm`. A char wider than the column on its own still | |
| 361 | + | /// gets its own piece (nothing narrower exists), so a piece is never empty. | |
| 362 | + | fn hard_break(word: &str, style: Style, max_width_mm: f32) -> Vec<String> { | |
| 363 | + | let mut pieces = Vec::new(); | |
| 364 | + | let mut cur = String::new(); | |
| 365 | + | for ch in word.chars() { | |
| 366 | + | cur.push(ch); | |
| 367 | + | if text_width_mm(&cur, style.size_pt, style.bold, style.mono) > max_width_mm | |
| 368 | + | && cur.chars().count() > 1 | |
| 369 | + | { | |
| 370 | + | cur.pop(); | |
| 371 | + | pieces.push(std::mem::take(&mut cur)); | |
| 372 | + | cur.push(ch); | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | if !cur.is_empty() { | |
| 376 | + | pieces.push(cur); | |
| 377 | + | } | |
| 378 | + | pieces | |
| 379 | + | } | |
| 380 | + | ||
| 345 | 381 | #[cfg(test)] | |
| 346 | 382 | mod tests { | |
| 347 | 383 | use super::*; | |
| @@ -363,6 +399,28 @@ mod tests { | |||
| 363 | 399 | } | |
| 364 | 400 | ||
| 365 | 401 | #[test] | |
| 402 | + | fn wrap_hard_breaks_overlong_token() { | |
| 403 | + | // A single token far wider than the column must be split, and no resulting | |
| 404 | + | // line may exceed the column width. | |
| 405 | + | let long = "supercalifragilisticexpialidocioussupercalifragilistic"; | |
| 406 | + | let lines = wrap(long, Style::body(), 20.0); | |
| 407 | + | assert!( | |
| 408 | + | lines.len() > 1, | |
| 409 | + | "an over-wide token should wrap onto several lines" | |
| 410 | + | ); | |
| 411 | + | let style = Style::body(); | |
| 412 | + | for l in &lines { | |
| 413 | + | assert!(!l.is_empty()); | |
| 414 | + | assert!( | |
| 415 | + | text_width_mm(l, style.size_pt, style.bold, style.mono) <= 20.0, | |
| 416 | + | "line {l:?} exceeds the column width", | |
| 417 | + | ); | |
| 418 | + | } | |
| 419 | + | // No characters are lost across the break. | |
| 420 | + | assert_eq!(lines.concat(), long); | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | #[test] | |
| 366 | 424 | fn empty_body_is_error() { | |
| 367 | 425 | let out = render_article("<p></p>", "t", None); | |
| 368 | 426 | assert!(matches!(out, Err(Error::Empty))); | |
| @@ -370,12 +428,12 @@ mod tests { | |||
| 370 | 428 | ||
| 371 | 429 | #[test] | |
| 372 | 430 | fn end_to_end_produces_pdf_bytes() { | |
| 373 | - | let html = r#" | |
| 431 | + | let html = r" | |
| 374 | 432 | <h1>Hello</h1> | |
| 375 | 433 | <p>This is a <strong>bold</strong> paragraph with an <em>italic</em> word.</p> | |
| 376 | 434 | <ul><li>one</li><li>two</li></ul> | |
| 377 | 435 | <blockquote>Quoted material.</blockquote> | |
| 378 | - | "#; | |
| 436 | + | "; | |
| 379 | 437 | let bytes = render_article(html, "Test Article", Some("https://example.com/a")).unwrap(); | |
| 380 | 438 | assert!(bytes.starts_with(b"%PDF")); | |
| 381 | 439 | assert!(bytes.len() > 1000); |
| @@ -57,6 +57,14 @@ | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | /** True if a resource URL uses a dangerous scheme or points off-origin. */ | |
| 61 | + | function isBlockedResourceUrl(url) { | |
| 62 | + | const value = url.replace(/[\s\x00-\x1f]/g, '').toLowerCase(); | |
| 63 | + | return value.startsWith('javascript:') || value.startsWith('data:') || | |
| 64 | + | value.startsWith('vbscript:') || value.startsWith('http:') || | |
| 65 | + | value.startsWith('https:') || value.startsWith('//'); | |
| 66 | + | } | |
| 67 | + | ||
| 60 | 68 | /** Remove dangerous attributes from a single element. */ | |
| 61 | 69 | function cleanAttributes(el) { | |
| 62 | 70 | // Collect names first; removing during iteration can skip entries. | |
| @@ -80,13 +88,17 @@ | |||
| 80 | 88 | // render with no user action, so a remote URL here beacons the | |
| 81 | 89 | // reader's IP to the feed publisher (tracking pixel). Strip both | |
| 82 | 90 | // dangerous schemes and any remote (http/https/protocol-relative) | |
| 83 | - | // target. Normalize away whitespace/control chars to defeat | |
| 84 | - | // obfuscation. Relative URLs resolve against the app origin and | |
| 85 | - | // don't leak, so they're left intact. | |
| 91 | + | // target. Relative URLs resolve against the app origin and don't | |
| 92 | + | // leak, so they're left intact. | |
| 86 | 93 | if (lower === 'src' || lower === 'srcset' || lower === 'poster') { | |
| 87 | - | const value = (el.getAttribute(name) || '').replace(/[\s\x00-\x1f]/g, '').toLowerCase(); | |
| 88 | - | if (value.startsWith('javascript:') || value.startsWith('data:') || value.startsWith('vbscript:') || | |
| 89 | - | value.startsWith('http:') || value.startsWith('https:') || value.startsWith('//')) { | |
| 94 | + | const raw = el.getAttribute(name) || ''; | |
| 95 | + | // srcset is a comma-separated candidate list ("url 1x, url 2x"); | |
| 96 | + | // each candidate's URL must be checked on its own, or a remote | |
| 97 | + | // URL in any position but the first slips past a whole-value test. | |
| 98 | + | const urls = lower === 'srcset' | |
| 99 | + | ? raw.split(',').map(c => c.trim().split(/\s+/, 1)[0]) | |
| 100 | + | : [raw]; | |
| 101 | + | if (urls.some(isBlockedResourceUrl)) { | |
| 90 | 102 | el.removeAttribute(name); | |
| 91 | 103 | } | |
| 92 | 104 | continue; |