Skip to main content

htb_cli/models/
challenge.rs

1use serde::{Deserialize, Serialize};
2
3use super::{deserialize_bool_or_null, deserialize_string_or_int};
4use crate::output::Tabular;
5
6#[derive(Debug, Deserialize, Serialize)]
7pub struct Challenge {
8    pub id: u64,
9    pub name: String,
10    #[serde(default)]
11    pub difficulty: Option<String>,
12    #[serde(default)]
13    pub state: Option<String>,
14    #[serde(default)]
15    pub category_id: Option<u64>,
16    #[serde(default)]
17    pub category_name: Option<String>,
18    #[serde(default)]
19    pub solves: u32,
20    #[serde(default)]
21    pub is_owned: bool,
22    #[serde(default)]
23    pub rating: Option<f64>,
24    #[serde(default)]
25    pub rating_count: Option<u32>,
26    #[serde(default)]
27    pub release_date: Option<String>,
28    #[serde(default)]
29    pub play_methods: Vec<String>,
30    #[serde(default)]
31    pub labels: Vec<super::machine::Label>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct ChallengeDetailResponse {
36    pub challenge: ChallengeDetail,
37}
38
39#[derive(Debug, Deserialize, Serialize)]
40pub struct ChallengeDetail {
41    pub id: u64,
42    pub name: String,
43    #[serde(default)]
44    pub difficulty: Option<String>,
45    #[serde(default, deserialize_with = "deserialize_string_or_int")]
46    pub points: Option<String>,
47    #[serde(default)]
48    pub state: Option<String>,
49    #[serde(default)]
50    pub solves: u32,
51    #[serde(default)]
52    pub stars: Option<f64>,
53    #[serde(default)]
54    pub description: Option<String>,
55    #[serde(default)]
56    pub category_name: Option<String>,
57    #[serde(default)]
58    pub first_blood_user: Option<String>,
59    #[serde(default)]
60    pub first_blood_time: Option<String>,
61    #[serde(default)]
62    pub creator_name: Option<String>,
63    #[serde(default, deserialize_with = "deserialize_bool_or_null")]
64    pub download: bool,
65    #[serde(default)]
66    pub file_name: Option<String>,
67    #[serde(default)]
68    pub file_size: Option<String>,
69    #[serde(default)]
70    pub play_info: Option<ChallengePlayInfo>,
71    #[serde(default)]
72    pub play_methods: Vec<String>,
73    #[serde(default)]
74    pub release_date: Option<String>,
75    #[serde(
76        default,
77        rename = "authUserSolve",
78        deserialize_with = "deserialize_bool_or_null"
79    )]
80    pub auth_user_solve: bool,
81    #[serde(default)]
82    pub experience_points: Option<u32>,
83}
84
85#[derive(Debug, Deserialize, Serialize)]
86pub struct ChallengePlayInfo {
87    #[serde(default)]
88    pub status: Option<String>,
89    #[serde(default)]
90    pub expires_at: Option<String>,
91    #[serde(default)]
92    pub ip: Option<String>,
93    #[serde(default)]
94    pub ports: Option<serde_json::Value>,
95}
96
97#[derive(Debug, Deserialize, Serialize)]
98pub struct ChallengeCategory {
99    pub id: u64,
100    pub name: String,
101    #[serde(default)]
102    pub icon: Option<String>,
103}
104
105#[derive(Debug, Deserialize)]
106pub struct ChallengeCategoriesResponse {
107    pub info: Vec<ChallengeCategory>,
108}
109
110#[derive(Debug, Deserialize)]
111pub struct ChallengeDownloadResponse {
112    pub url: String,
113    #[serde(default)]
114    pub expires_in: Option<u64>,
115}
116
117#[derive(Debug, Deserialize)]
118pub struct ChallengeWriteupResponse {
119    pub data: ChallengeWriteupData,
120}
121
122#[derive(Debug, Deserialize)]
123pub struct ChallengeWriteupData {
124    #[serde(default)]
125    pub official: Option<ChallengeWriteup>,
126}
127
128#[derive(Debug, Deserialize)]
129pub struct ChallengeWriteup {
130    #[serde(default)]
131    pub filename: Option<String>,
132    #[serde(default)]
133    pub url: Option<String>,
134}
135
136#[derive(Debug, Deserialize, Serialize)]
137pub struct ChallengeOwnResponse {
138    pub message: String,
139    #[serde(default)]
140    pub user_rank: Option<ChallengeOwnRank>,
141}
142
143#[derive(Debug, Deserialize, Serialize)]
144pub struct ChallengeOwnRank {
145    #[serde(default)]
146    pub changed: bool,
147}
148
149impl Tabular for Challenge {
150    fn headers() -> Vec<&'static str> {
151        vec![
152            "ID",
153            "Name",
154            "Difficulty",
155            "Category",
156            "Solves",
157            "Rating",
158            "Owned",
159            "State",
160        ]
161    }
162
163    fn row(&self) -> Vec<String> {
164        vec![
165            self.id.to_string(),
166            self.name.clone(),
167            self.difficulty.clone().unwrap_or_default(),
168            self.category_name.clone().unwrap_or_default(),
169            self.solves.to_string(),
170            self.rating.map(|r| format!("{r:.1}")).unwrap_or_default(),
171            if self.is_owned { "✓" } else { "" }.to_string(),
172            self.state.clone().unwrap_or_default(),
173        ]
174    }
175}
176
177impl Tabular for ChallengeCategory {
178    fn headers() -> Vec<&'static str> {
179        vec!["ID", "Name"]
180    }
181
182    fn row(&self) -> Vec<String> {
183        vec![self.id.to_string(), self.name.clone()]
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::models::Paginated;
191
192    #[test]
193    fn deserialize_challenges_list() {
194        let json = include_str!("../../tests/fixtures/challenges-list.json");
195        let result: Paginated<Challenge> = serde_json::from_str(json).unwrap();
196        assert_eq!(result.data.len(), 15);
197        assert_eq!(result.data[0].name, "SpookyPass");
198        assert_eq!(result.data[0].category_name.as_deref(), Some("Reversing"));
199    }
200
201    #[test]
202    fn deserialize_challenge_detail() {
203        let json = include_str!("../../tests/fixtures/challenge-info-poly.json");
204        let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
205        assert_eq!(result.challenge.name, "Poly");
206        assert_eq!(result.challenge.difficulty.as_deref(), Some("Insane"));
207        assert_eq!(result.challenge.category_name.as_deref(), Some("Reversing"));
208    }
209
210    #[test]
211    fn deserialize_challenge_detail_int_points() {
212        let json = include_str!("../../tests/fixtures/challenge-info-int-points.json");
213        let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
214        assert_eq!(result.challenge.name, "TestIntPoints");
215        assert_eq!(result.challenge.points.as_deref(), Some("0"));
216    }
217
218    #[test]
219    fn deserialize_challenge_categories() {
220        let json = include_str!("../../tests/fixtures/challenge-categories.json");
221        let result: ChallengeCategoriesResponse = serde_json::from_str(json).unwrap();
222        assert!(!result.info.is_empty());
223        assert!(result.info.iter().any(|c| c.name == "Web"));
224    }
225
226    #[test]
227    fn deserialize_challenge_own_success() {
228        let json = include_str!("../../tests/fixtures/challenge-own-success.json");
229        let result: ChallengeOwnResponse = serde_json::from_str(json).unwrap();
230        assert_eq!(result.message, "Congratulations!");
231    }
232
233    #[test]
234    fn deserialize_challenge_reversing_download_only() {
235        let json = include_str!("../../tests/fixtures/challenge-info-reversing.json");
236        let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
237        let c = &result.challenge;
238        assert_eq!(c.name, "TestChallenge-Rev");
239        assert_eq!(c.category_name.as_deref(), Some("Reversing"));
240        assert_eq!(c.points.as_deref(), Some("0"));
241        assert!(c.download);
242        assert!(c.file_name.is_some());
243        assert_eq!(c.play_methods, vec!["download"]);
244        assert!(c.play_info.is_some());
245    }
246
247    #[test]
248    fn deserialize_challenge_web_docker_active() {
249        let json = include_str!("../../tests/fixtures/challenge-info-web-docker.json");
250        let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
251        let c = &result.challenge;
252        assert_eq!(c.name, "TestChallenge-Web");
253        assert_eq!(c.category_name.as_deref(), Some("Web"));
254        assert_eq!(c.points.as_deref(), Some("0"));
255        assert!(!c.download);
256        assert!(c.file_name.is_none());
257        assert_eq!(c.play_methods, vec!["container"]);
258        let info = c.play_info.as_ref().unwrap();
259        assert_eq!(info.status.as_deref(), Some("ready"));
260        assert!(info.ip.is_some());
261        assert!(info.ports.is_some());
262        assert!(info.expires_at.is_some());
263    }
264
265    #[test]
266    fn deserialize_challenge_hybrid_docker_and_download() {
267        let json = include_str!("../../tests/fixtures/challenge-info-hybrid.json");
268        let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
269        let c = &result.challenge;
270        assert_eq!(c.name, "TestChallenge-Hybrid");
271        assert!(c.download);
272        assert!(c.file_name.is_some());
273        assert!(c.play_methods.contains(&"download".to_string()));
274        assert!(c.play_methods.contains(&"container".to_string()));
275    }
276}