Skip to content

Commit 7cb86ed

Browse files
authored
fix(fetchers): harden youtube transcript handling (#110)
Bound timedtext XML body via `options.max_body_size` before joining segments to prevent unbounded memory use. Replace the unsafe `&transcript[..15000]` byte-slice with a `safe_truncate_utf8` helper that returns truncation at the nearest UTF-8 char boundary, eliminating a panic on multibyte boundaries.
1 parent cf05874 commit 7cb86ed

1 file changed

Lines changed: 38 additions & 3 deletions

File tree

crates/fetchkit/src/fetchers/youtube.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use std::time::Duration;
1515
use url::Url;
1616

1717
const API_TIMEOUT: Duration = Duration::from_secs(10);
18+
const MAX_TRANSCRIPT_CHARS: usize = 15_000;
1819

1920
/// YouTube video fetcher
2021
///
@@ -143,7 +144,8 @@ impl Fetcher for YouTubeFetcher {
143144
let author_url = oembed.as_ref().and_then(|o| o.author_url.clone());
144145

145146
// Attempt transcript extraction via timedtext API
146-
let transcript = fetch_transcript(&client, &ua_header, &video_id).await;
147+
let transcript =
148+
fetch_transcript(&client, &ua_header, &video_id, options.max_body_size).await;
147149

148150
let content = format_youtube_response(
149151
&title,
@@ -171,6 +173,7 @@ async fn fetch_transcript(
171173
client: &reqwest::Client,
172174
ua: &HeaderValue,
173175
video_id: &str,
176+
max_body_size: Option<usize>,
174177
) -> Option<String> {
175178
// Try the legacy timedtext API (auto-generated English captions)
176179
let timedtext_url = format!(
@@ -190,6 +193,11 @@ async fn fetch_transcript(
190193
}
191194

192195
let xml = resp.text().await.ok()?;
196+
if let Some(max_body_size) = max_body_size {
197+
if xml.len() > max_body_size {
198+
return None;
199+
}
200+
}
193201
if xml.is_empty() || !xml.contains("<text") {
194202
return None;
195203
}
@@ -280,8 +288,9 @@ fn format_youtube_response(
280288
if let Some(transcript) = transcript {
281289
out.push_str("\n## Transcript\n\n");
282290
// Truncate very long transcripts
283-
if transcript.len() > 15000 {
284-
out.push_str(&transcript[..15000]);
291+
if transcript.len() > MAX_TRANSCRIPT_CHARS {
292+
let truncated = safe_truncate_utf8(transcript, MAX_TRANSCRIPT_CHARS);
293+
out.push_str(truncated);
285294
out.push_str("\n\n*[Transcript truncated]*\n");
286295
} else {
287296
out.push_str(transcript);
@@ -294,6 +303,24 @@ fn format_youtube_response(
294303
out
295304
}
296305

306+
fn safe_truncate_utf8(input: &str, max_bytes: usize) -> &str {
307+
if input.len() <= max_bytes {
308+
return input;
309+
}
310+
311+
if input.is_char_boundary(max_bytes) {
312+
return &input[..max_bytes];
313+
}
314+
315+
let idx = input
316+
.char_indices()
317+
.map(|(i, _)| i)
318+
.take_while(|&i| i < max_bytes)
319+
.last()
320+
.unwrap_or(0);
321+
&input[..idx]
322+
}
323+
297324
#[cfg(test)]
298325
mod tests {
299326
use super::*;
@@ -454,4 +481,12 @@ mod tests {
454481
assert_eq!(decode_xml_entities("&lt;tag&gt;"), "<tag>");
455482
assert_eq!(decode_xml_entities("it&#39;s"), "it's");
456483
}
484+
485+
#[test]
486+
fn test_safe_truncate_utf8_multibyte_boundary() {
487+
let input = format!("{}érest", "a".repeat(14_999));
488+
let truncated = safe_truncate_utf8(&input, 15_000);
489+
assert_eq!(truncated.len(), 14_999);
490+
assert!(truncated.is_char_boundary(truncated.len()));
491+
}
457492
}

0 commit comments

Comments
 (0)