1use std::{collections::BTreeMap, time::Duration};
2
3use reqwest::{Client as HttpClient, ClientBuilder, redirect::Policy};
4use serde::{Deserialize, de::DeserializeOwned};
5
6use crate::{
7 Difficulty, Error, Problem, ProblemList, ProblemSummary, Profile, Sample, Starter, Track,
8};
9
10pub(crate) const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
11const MAX_STATEMENT_BYTES: usize = 512 * 1024;
12const MAX_TEXT_BYTES: usize = 128 * 1024;
13const MAX_LANGUAGES: usize = 64;
14const MAX_STARTERS: usize = 64;
15const MAX_SAMPLES: usize = 16;
16const MAX_TEMPLATE_BYTES: usize = 128 * 1024;
17const MAX_SAMPLE_BYTES: usize = 64 * 1024;
18const MAX_PROFILE_TEXT_BYTES: usize = 256;
19const MAX_PROFILE_LEVEL: u32 = 100_000;
20const MAX_PROFILE_EVENTS: u32 = 10_000_000;
21const MAX_LIST_LIMIT: u8 = 20;
22const MAX_OFFSET: u32 = 1_000_000;
23const MAX_TOTAL: u64 = 10_000_000;
24const MAX_PROBLEMS: usize = MAX_LIST_LIMIT as usize;
25
26pub struct Client {
27 pub(crate) http: HttpClient,
28 pub(crate) endpoint: Box<str>,
29}
30
31impl Client {
32 pub fn new() -> Result<Self, Error> {
33 Ok(Self {
34 http: http_builder().build()?,
35 endpoint: "https://www.hackerrank.com/".into(),
36 })
37 }
38
39 pub async fn list_track(
41 &self,
42 track: &str,
43 offset: u32,
44 limit: u8,
45 progress: impl FnMut(usize, Option<u64>),
46 ) -> Result<ProblemList, Error> {
47 if !valid_slug(track) {
48 return Err(Error::InvalidSlug);
49 }
50 if offset > MAX_OFFSET || limit == 0 || limit > MAX_LIST_LIMIT {
51 return Err(Error::InvalidPage);
52 }
53
54 let path = format!("rest/contests/master/tracks/{track}/challenges");
55 let response: ListResponse = self
56 .get_json(
57 &path,
58 &[
59 ("offset", offset.to_string()),
60 ("limit", limit.to_string()),
61 ("track_login", "true".to_owned()),
62 ],
63 progress,
64 )
65 .await?;
66 list_from_response(response)
67 }
68
69 pub async fn problem(
71 &self,
72 slug: &str,
73 progress: impl FnMut(usize, Option<u64>),
74 ) -> Result<Problem, Error> {
75 if !valid_slug(slug) {
76 return Err(Error::InvalidSlug);
77 }
78
79 let path = format!("rest/contests/master/challenges/{slug}");
80 let response: ProblemResponse = self.get_json(&path, &[], progress).await?;
81 if !response.status {
82 return Err(Error::NotFound);
83 }
84 let model = response.model.ok_or(Error::NotFound)?;
85 problem_from_model(model, Some(slug))
86 }
87
88 pub async fn profile(
90 &self,
91 username: &str,
92 progress: impl FnMut(usize, Option<u64>),
93 ) -> Result<Profile, Error> {
94 if !valid_username(username) {
95 return Err(Error::InvalidUsername);
96 }
97 let path = format!("rest/contests/master/hackers/{username}/profile");
98 let response: ProfileResponse = self.get_json(&path, &[], progress).await?;
99 profile_from_model(response.model.ok_or(Error::NotFound)?)
100 }
101
102 async fn get_json<T: DeserializeOwned>(
103 &self,
104 path: &str,
105 query: &[(&'static str, String)],
106 progress: impl FnMut(usize, Option<u64>),
107 ) -> Result<T, Error> {
108 let mut url = self.endpoint_path(path)?;
109 if !query.is_empty() {
110 url.query_pairs_mut().extend_pairs(query);
111 }
112 let response = self.http.get(url).send().await?;
113 if response.status() == reqwest::StatusCode::NOT_FOUND {
114 return Err(Error::NotFound);
115 }
116 if !response.status().is_success() {
117 return Err(Error::Status(response.status()));
118 }
119 read_response(response, progress).await
120 }
121
122 fn endpoint_path(&self, path: &str) -> Result<reqwest::Url, Error> {
123 let mut url = reqwest::Url::parse(&self.endpoint).map_err(|_| Error::InvalidResponse)?;
124 url.set_path(path);
125 url.set_query(None);
126 Ok(url)
127 }
128}
129
130pub(crate) fn http_builder() -> ClientBuilder {
131 HttpClient::builder()
132 .https_only(true)
133 .connect_timeout(Duration::from_secs(10))
134 .read_timeout(Duration::from_secs(20))
135 .timeout(Duration::from_secs(30))
136 .redirect(Policy::none())
137 .retry(reqwest::retry::never())
138 .no_gzip()
139 .no_brotli()
140 .no_deflate()
141 .no_zstd()
142 .pool_max_idle_per_host(2)
143 .user_agent(concat!(
144 env!("CARGO_PKG_NAME"),
145 "/",
146 env!("CARGO_PKG_VERSION")
147 ))
148}
149
150async fn read_response<T: DeserializeOwned>(
151 mut response: reqwest::Response,
152 mut progress: impl FnMut(usize, Option<u64>),
153) -> Result<T, Error> {
154 if response
155 .content_length()
156 .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
157 {
158 return Err(Error::ResponseTooLarge {
159 limit: MAX_RESPONSE_BYTES,
160 });
161 }
162
163 let total = response.content_length();
164 let mut body = Vec::with_capacity(8 * 1024);
165 progress(0, total);
166 while let Some(chunk) = response.chunk().await? {
167 if chunk.len() > MAX_RESPONSE_BYTES - body.len() {
168 return Err(Error::ResponseTooLarge {
169 limit: MAX_RESPONSE_BYTES,
170 });
171 }
172 body.extend_from_slice(&chunk);
173 progress(body.len(), total);
174 }
175 Ok(serde_json::from_slice(&body)?)
176}
177
178#[derive(Deserialize)]
179struct ListResponse {
180 models: Vec<ChallengeModel>,
181 total: u64,
182}
183
184#[derive(Deserialize)]
185struct ProblemResponse {
186 status: bool,
187 model: Option<ChallengeModel>,
188}
189
190#[derive(Deserialize)]
191struct ProfileResponse {
192 model: Option<ProfileModel>,
193}
194
195#[derive(Deserialize)]
196struct ProfileModel {
197 id: u64,
198 username: Box<str>,
199 name: Option<Box<str>>,
200 country: Option<Box<str>>,
201 level: Option<u32>,
202 event_count: Option<u32>,
203 created_at: Option<Box<str>>,
204}
205
206#[derive(Deserialize)]
207struct ChallengeModel {
208 id: u64,
209 slug: Box<str>,
210 name: Box<str>,
211 difficulty_name: Option<Box<str>>,
212 preview: Option<Box<str>>,
213 track: Option<TrackModel>,
214 problem_statement: Option<Box<str>>,
215 input_format: Option<Box<str>>,
216 output_format: Option<Box<str>>,
217 #[serde(default)]
218 languages: Vec<Box<str>>,
219 #[serde(default)]
220 onboarding: Option<BTreeMap<Box<str>, OnboardingEntry>>,
221 #[serde(default)]
222 sample_test_cases: Option<Vec<SampleModel>>,
223 #[serde(default)]
224 public_test_cases: bool,
225}
226
227#[derive(Deserialize)]
228#[serde(untagged)]
229enum OnboardingEntry {
230 Template { template: Option<Box<str>> },
231 Text(Box<str>),
232}
233
234#[derive(Deserialize)]
235struct SampleModel {
236 input: Box<str>,
237 output: Box<str>,
238}
239
240#[derive(Deserialize)]
241struct TrackModel {
242 slug: Box<str>,
243 name: Box<str>,
244 track_slug: Box<str>,
245 track_name: Box<str>,
246}
247
248fn list_from_response(response: ListResponse) -> Result<ProblemList, Error> {
249 if response.total > MAX_TOTAL || response.models.len() > MAX_PROBLEMS {
250 return Err(Error::InvalidResponse);
251 }
252 let problems = response
253 .models
254 .into_iter()
255 .map(|model| summary_from_model(model, None))
256 .collect::<Result<Vec<_>, _>>()?;
257 Ok(ProblemList {
258 total: response.total as u32,
259 problems,
260 })
261}
262
263fn problem_from_model(
264 model: ChallengeModel,
265 expected_slug: Option<&str>,
266) -> Result<Problem, Error> {
267 let ChallengeModel {
268 id,
269 slug,
270 name,
271 difficulty_name,
272 preview,
273 track,
274 problem_statement,
275 input_format,
276 output_format,
277 languages,
278 onboarding,
279 sample_test_cases,
280 public_test_cases,
281 } = model;
282 let statement = checked_text(problem_statement, MAX_STATEMENT_BYTES)?;
283 let input_format = checked_text(input_format, MAX_TEXT_BYTES)?;
284 let output_format = checked_text(output_format, MAX_TEXT_BYTES)?;
285 if languages.len() > MAX_LANGUAGES || languages.iter().any(|language| !valid_language(language))
286 {
287 return Err(Error::InvalidResponse);
288 }
289 let starters = starters_from_onboarding(onboarding)?;
290 let samples = samples_from_model(sample_test_cases)?;
291 let summary = summary_from_parts(
292 id,
293 slug,
294 name,
295 difficulty_name,
296 preview,
297 track,
298 expected_slug,
299 )?;
300 Ok(Problem {
301 summary,
302 statement,
303 input_format,
304 output_format,
305 languages,
306 starters,
307 samples,
308 has_public_test_cases: public_test_cases,
309 })
310}
311
312fn starters_from_onboarding(
313 onboarding: Option<BTreeMap<Box<str>, OnboardingEntry>>,
314) -> Result<Vec<Starter>, Error> {
315 let mut starters = Vec::new();
316 for (language, entry) in onboarding.unwrap_or_default() {
317 let template = match entry {
318 OnboardingEntry::Template {
319 template: Some(template),
320 } => template,
321 OnboardingEntry::Template { template: None } => continue,
322 OnboardingEntry::Text(text) => {
323 let _ = text.len();
324 continue;
325 }
326 };
327 if !valid_language(&language) || !valid_text(&template, MAX_TEMPLATE_BYTES) {
328 return Err(Error::InvalidResponse);
329 }
330 starters.push(Starter { language, template });
331 if starters.len() > MAX_STARTERS {
332 return Err(Error::InvalidResponse);
333 }
334 }
335 Ok(starters)
336}
337
338fn samples_from_model(samples: Option<Vec<SampleModel>>) -> Result<Vec<Sample>, Error> {
339 let samples = samples.unwrap_or_default();
340 if samples.len() > MAX_SAMPLES {
341 return Err(Error::InvalidResponse);
342 }
343 samples
344 .into_iter()
345 .map(|sample| {
346 if !valid_text(&sample.input, MAX_SAMPLE_BYTES)
347 || !valid_text(&sample.output, MAX_SAMPLE_BYTES)
348 {
349 return Err(Error::InvalidResponse);
350 }
351 Ok(Sample {
352 input: sample.input,
353 output: sample.output,
354 })
355 })
356 .collect()
357}
358
359fn profile_from_model(model: ProfileModel) -> Result<Profile, Error> {
360 if model.id == 0
361 || !valid_username(&model.username)
362 || model.level.is_some_and(|level| level > MAX_PROFILE_LEVEL)
363 || model
364 .event_count
365 .is_some_and(|events| events > MAX_PROFILE_EVENTS)
366 {
367 return Err(Error::InvalidResponse);
368 }
369 Ok(Profile {
370 id: model.id,
371 username: model.username,
372 name: profile_text(model.name)?,
373 country: profile_text(model.country)?,
374 level: model.level,
375 event_count: model.event_count,
376 created_at: profile_text(model.created_at)?,
377 })
378}
379
380fn profile_text(value: Option<Box<str>>) -> Result<Option<Box<str>>, Error> {
381 let Some(value) = value else {
382 return Ok(None);
383 };
384 let value = value.trim();
385 if value.is_empty() {
386 return Ok(None);
387 }
388 valid_text(value, MAX_PROFILE_TEXT_BYTES)
389 .then(|| Some(value.into()))
390 .ok_or(Error::InvalidResponse)
391}
392
393fn summary_from_model(
394 model: ChallengeModel,
395 expected_slug: Option<&str>,
396) -> Result<ProblemSummary, Error> {
397 summary_from_parts(
398 model.id,
399 model.slug,
400 model.name,
401 model.difficulty_name,
402 model.preview,
403 model.track,
404 expected_slug,
405 )
406}
407
408fn summary_from_parts(
409 id: u64,
410 slug: Box<str>,
411 title: Box<str>,
412 difficulty_name: Option<Box<str>>,
413 preview: Option<Box<str>>,
414 track: Option<TrackModel>,
415 expected_slug: Option<&str>,
416) -> Result<ProblemSummary, Error> {
417 if id == 0
418 || !valid_slug(&slug)
419 || expected_slug.is_some_and(|expected| expected != slug.as_ref())
420 || !valid_text(&title, 256)
421 || preview
422 .as_deref()
423 .is_some_and(|text| !valid_text(text, MAX_TEXT_BYTES))
424 {
425 return Err(Error::InvalidResponse);
426 }
427 let track = track.map(track_from_model).transpose()?;
428 Ok(ProblemSummary {
429 id,
430 slug,
431 title,
432 difficulty: difficulty(difficulty_name.as_deref()),
433 preview,
434 track,
435 })
436}
437
438fn track_from_model(track: TrackModel) -> Result<Track, Error> {
439 if !valid_slug(&track.slug)
440 || !valid_slug(&track.track_slug)
441 || !valid_text(&track.name, 128)
442 || !valid_text(&track.track_name, 128)
443 {
444 return Err(Error::InvalidResponse);
445 }
446 Ok(Track {
447 slug: track.slug,
448 name: track.name,
449 domain_slug: track.track_slug,
450 domain_name: track.track_name,
451 })
452}
453
454fn checked_text(text: Option<Box<str>>, limit: usize) -> Result<Box<str>, Error> {
455 let text = text.ok_or(Error::InvalidResponse)?;
456 valid_text(&text, limit)
457 .then_some(text)
458 .ok_or(Error::InvalidResponse)
459}
460
461fn difficulty(value: Option<&str>) -> Difficulty {
462 match value {
463 Some("Easy") => Difficulty::Easy,
464 Some("Medium") => Difficulty::Medium,
465 Some("Hard") => Difficulty::Hard,
466 _ => Difficulty::Other,
467 }
468}
469
470fn valid_slug(value: &str) -> bool {
471 !value.is_empty()
472 && value.len() <= 128
473 && !value.starts_with('-')
474 && !value.ends_with('-')
475 && value
476 .bytes()
477 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
478}
479
480fn valid_language(value: &str) -> bool {
481 !value.is_empty()
482 && value.len() <= 32
483 && value
484 .bytes()
485 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
486}
487
488fn valid_username(value: &str) -> bool {
489 !value.is_empty()
490 && value.len() <= 128
491 && value
492 .bytes()
493 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
494}
495
496fn valid_text(value: &str, limit: usize) -> bool {
497 !value.is_empty() && value.len() <= limit && !value.as_bytes().contains(&0)
498}