Skip to main content

htb_cli/models/
ctf.rs

1use serde::{Deserialize, Serialize};
2
3use crate::output::Tabular;
4
5#[derive(Debug, Deserialize, Serialize)]
6pub struct CtfUserProfile {
7    pub id: u64,
8    pub name: String,
9    #[serde(default)]
10    pub full_name: Option<String>,
11    #[serde(default)]
12    pub email: Option<String>,
13    #[serde(default)]
14    pub timezone: Option<String>,
15    #[serde(default, rename = "hasAnyTeam")]
16    pub has_any_team: bool,
17    #[serde(default)]
18    pub avatar: Option<String>,
19}
20
21impl Tabular for CtfUserProfile {
22    fn headers() -> Vec<&'static str> {
23        vec!["ID", "Name", "Email", "Timezone"]
24    }
25
26    fn row(&self) -> Vec<String> {
27        vec![
28            self.id.to_string(),
29            self.name.clone(),
30            self.email.clone().unwrap_or_default(),
31            self.timezone.clone().unwrap_or_default(),
32        ]
33    }
34}
35
36#[derive(Debug, Deserialize, Serialize)]
37pub struct CtfEvent {
38    pub id: u64,
39    pub name: String,
40    #[serde(default)]
41    pub slug: Option<String>,
42    #[serde(default)]
43    pub status: Option<String>,
44    #[serde(default)]
45    pub starts_at: Option<String>,
46    #[serde(default)]
47    pub ends_at: Option<String>,
48    #[serde(default)]
49    pub format: Option<String>,
50    #[serde(default)]
51    pub team_size: Option<u32>,
52    #[serde(default)]
53    pub players: Option<u32>,
54    #[serde(default)]
55    pub org_name: Option<String>,
56    #[serde(default, rename = "hasJoined")]
57    pub has_joined: u32,
58    #[serde(default, rename = "joinedTeam")]
59    pub joined_team: Option<String>,
60    #[serde(default, rename = "membersJoined")]
61    pub members_joined: Option<String>,
62}
63
64impl Tabular for CtfEvent {
65    fn headers() -> Vec<&'static str> {
66        vec![
67            "ID",
68            "Name",
69            "Status",
70            "Format",
71            "Players",
72            "Team Size",
73            "Joined",
74        ]
75    }
76
77    fn row(&self) -> Vec<String> {
78        vec![
79            self.id.to_string(),
80            self.name.clone(),
81            self.status.clone().unwrap_or_default(),
82            self.format.clone().unwrap_or_default(),
83            self.players.map(|p| p.to_string()).unwrap_or_default(),
84            self.team_size.map(|t| t.to_string()).unwrap_or_default(),
85            if self.has_joined > 0 {
86                self.joined_team.clone().unwrap_or_else(|| "Yes".into())
87            } else {
88                String::new()
89            },
90        ]
91    }
92}
93
94#[derive(Debug, Deserialize, Serialize)]
95pub struct CtfEventDetail {
96    pub id: u64,
97    pub name: String,
98    #[serde(default)]
99    pub slug: Option<String>,
100    #[serde(default)]
101    pub status: Option<String>,
102    #[serde(default, rename = "startDate")]
103    pub start_date: Option<String>,
104    #[serde(default, rename = "endDate")]
105    pub end_date: Option<String>,
106    #[serde(default)]
107    pub description: Option<String>,
108    #[serde(default, rename = "type")]
109    pub event_type: Option<String>,
110    #[serde(default)]
111    pub format: Option<String>,
112    #[serde(default)]
113    pub location: Option<String>,
114    #[serde(default, rename = "playersJoined")]
115    pub players_joined: Option<u32>,
116    #[serde(default, rename = "teamsJoined")]
117    pub teams_joined: Option<u32>,
118    #[serde(default)]
119    pub challenges: Option<u32>,
120    #[serde(default, rename = "challengeCategories")]
121    pub challenge_categories: Option<u32>,
122    #[serde(default, rename = "maxTeamSize")]
123    pub max_team_size: Option<u32>,
124    #[serde(default, rename = "prizePool")]
125    pub prize_pool: Option<String>,
126    #[serde(default)]
127    pub featured: bool,
128    #[serde(default)]
129    pub ai_usage_policy: Option<String>,
130}
131
132#[derive(Debug, Deserialize, Serialize)]
133pub struct CtfEventData {
134    pub id: u64,
135    pub name: String,
136    #[serde(default)]
137    pub status: Option<String>,
138    #[serde(default)]
139    pub hide_scoreboard: u32,
140    #[serde(default)]
141    pub participating_team: Option<CtfParticipatingTeam>,
142    #[serde(default)]
143    pub challenges: Vec<CtfChallenge>,
144}
145
146#[derive(Debug, Deserialize, Serialize)]
147pub struct CtfParticipatingTeam {
148    pub id: u64,
149    pub name: String,
150    #[serde(default)]
151    pub points: Option<u32>,
152    #[serde(default)]
153    pub solved_challenges: Option<u32>,
154    #[serde(default)]
155    pub total_challenges: Option<u32>,
156    #[serde(default)]
157    pub owned_flags: Option<u32>,
158    #[serde(default)]
159    pub total_flags: Option<u32>,
160    #[serde(default)]
161    pub rank: Option<u32>,
162    #[serde(default, rename = "isCaptain")]
163    pub is_captain: bool,
164}
165
166#[derive(Debug, Deserialize, Serialize, Clone)]
167pub struct CtfChallenge {
168    pub id: u64,
169    pub name: String,
170    #[serde(default)]
171    pub creator: Option<String>,
172    #[serde(default)]
173    pub description: Option<String>,
174    #[serde(default)]
175    pub challenge_category_id: Option<u64>,
176    #[serde(default)]
177    pub difficulty: Option<String>,
178    #[serde(default)]
179    pub filename: Option<String>,
180    #[serde(default, rename = "hasDocker")]
181    pub has_docker: Option<u32>,
182    #[serde(default)]
183    pub docker_online: Option<String>,
184    #[serde(default)]
185    pub docker_ports: Option<String>,
186    #[serde(default)]
187    pub points: Option<u32>,
188    #[serde(default)]
189    pub solves: Option<u32>,
190    #[serde(default)]
191    pub solved: bool,
192    #[serde(default)]
193    pub status: Option<String>,
194    #[serde(default, rename = "flagsInfo")]
195    pub flags_info: Vec<CtfFlagInfo>,
196}
197
198impl Tabular for CtfChallenge {
199    fn headers() -> Vec<&'static str> {
200        vec![
201            "ID",
202            "Name",
203            "Difficulty",
204            "Points",
205            "Solves",
206            "Docker",
207            "Download",
208            "Flags",
209            "Solved",
210        ]
211    }
212
213    fn row(&self) -> Vec<String> {
214        vec![
215            self.id.to_string(),
216            self.name.clone(),
217            self.difficulty.clone().unwrap_or_default(),
218            self.points.map(|p| p.to_string()).unwrap_or_default(),
219            self.solves.map(|s| s.to_string()).unwrap_or_default(),
220            if self.has_docker.unwrap_or(0) > 0 {
221                "Yes"
222            } else {
223                ""
224            }
225            .to_string(),
226            if self.filename.is_some() { "Yes" } else { "" }.to_string(),
227            self.flags_info.len().to_string(),
228            if self.solved { "Yes" } else { "" }.to_string(),
229        ]
230    }
231}
232
233#[derive(Debug, Deserialize, Serialize, Clone)]
234pub struct CtfFlagInfo {
235    pub flag_id: u64,
236    #[serde(default)]
237    pub identifier: Option<String>,
238    #[serde(default)]
239    pub question: Option<String>,
240    #[serde(default)]
241    pub solved: bool,
242}
243
244#[derive(Debug, Deserialize, Serialize)]
245pub struct CtfMenu {
246    pub id: u64,
247    #[serde(default, rename = "userCanViewScoreboard")]
248    pub user_can_view_scoreboard: Option<u32>,
249    #[serde(default)]
250    pub status: Option<String>,
251}
252
253#[derive(Debug, Deserialize, Serialize)]
254pub struct CtfScoreboard {
255    #[serde(default)]
256    pub ctf_name: Option<String>,
257    #[serde(default)]
258    pub ctf_teams: Option<u32>,
259    #[serde(default)]
260    pub ctf_players: Option<u32>,
261    #[serde(default)]
262    pub is_ended: bool,
263    #[serde(default)]
264    pub participating_team: Option<CtfScoreboardTeam>,
265    #[serde(default)]
266    pub scores: Vec<CtfTeamScore>,
267}
268
269#[derive(Debug, Deserialize, Serialize)]
270pub struct CtfScoreboardTeam {
271    pub id: u64,
272    pub name: String,
273    #[serde(default)]
274    pub position: Option<u32>,
275    #[serde(default)]
276    pub points: Option<u32>,
277    #[serde(default)]
278    pub solved_challenges: Option<u32>,
279    #[serde(default)]
280    pub total_challenges: Option<u32>,
281    #[serde(default)]
282    pub owned_flags: Option<u32>,
283    #[serde(default)]
284    pub first_bloods: Option<u32>,
285}
286
287#[derive(Debug, Deserialize, Serialize)]
288pub struct CtfTeamScore {
289    pub id: u64,
290    pub name: String,
291    #[serde(default)]
292    pub country_code: Option<String>,
293    #[serde(default)]
294    pub points: Option<u32>,
295    #[serde(default)]
296    pub owned_flags: Option<u32>,
297    #[serde(default)]
298    pub first_bloods: Option<u32>,
299}
300
301impl Tabular for CtfTeamScore {
302    fn headers() -> Vec<&'static str> {
303        vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
304    }
305
306    fn row(&self) -> Vec<String> {
307        vec![
308            String::new(), // rank filled by display
309            self.name.clone(),
310            self.country_code.clone().unwrap_or_default(),
311            self.points.map(|p| p.to_string()).unwrap_or_default(),
312            self.owned_flags.map(|f| f.to_string()).unwrap_or_default(),
313            self.first_bloods.map(|b| b.to_string()).unwrap_or_default(),
314        ]
315    }
316}
317
318#[derive(Debug, Deserialize, Serialize)]
319pub struct CtfSolve {
320    #[serde(default)]
321    pub challenge_name: Option<String>,
322    #[serde(default)]
323    pub challenge_category: Option<String>,
324    #[serde(default)]
325    pub team_name: Option<String>,
326    #[serde(default)]
327    pub user_name: Option<String>,
328    #[serde(default)]
329    pub created_at: Option<String>,
330}
331
332impl Tabular for CtfSolve {
333    fn headers() -> Vec<&'static str> {
334        vec!["Challenge", "Category", "Team", "Player", "Time"]
335    }
336
337    fn row(&self) -> Vec<String> {
338        vec![
339            self.challenge_name.clone().unwrap_or_default(),
340            self.challenge_category.clone().unwrap_or_default(),
341            self.team_name.clone().unwrap_or_default(),
342            self.user_name.clone().unwrap_or_default(),
343            self.created_at.clone().unwrap_or_default(),
344        ]
345    }
346}
347
348#[derive(Debug, Deserialize, Serialize)]
349pub struct CtfChallengeSolve {
350    #[serde(default)]
351    pub team_id: Option<u64>,
352    #[serde(default)]
353    pub team_name: Option<String>,
354    #[serde(default)]
355    pub date_of_latest_own: Option<String>,
356    #[serde(default)]
357    pub user_of_latest_own: Option<CtfSolveUser>,
358}
359
360#[derive(Debug, Deserialize, Serialize)]
361pub struct CtfSolveUser {
362    pub name: String,
363}
364
365impl Tabular for CtfChallengeSolve {
366    fn headers() -> Vec<&'static str> {
367        vec!["Team", "Player", "Time"]
368    }
369
370    fn row(&self) -> Vec<String> {
371        vec![
372            self.team_name.clone().unwrap_or_default(),
373            self.user_of_latest_own
374                .as_ref()
375                .map(|u| u.name.clone())
376                .unwrap_or_default(),
377            self.date_of_latest_own.clone().unwrap_or_default(),
378        ]
379    }
380}
381
382#[derive(Debug, Deserialize, Serialize)]
383pub struct CtfFlagResult {
384    pub message: String,
385    #[serde(default)]
386    pub points: Option<u32>,
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn deserialize_ctf_events_fixture() {
395        let json = include_str!("../../tests/fixtures/ctf/events.json");
396        let events: Vec<CtfEvent> = serde_json::from_str(json).unwrap();
397        assert_eq!(events.len(), 2);
398        assert_eq!(events[0].name, "CTF Try Out");
399        assert_eq!(events[0].has_joined, 1);
400        assert_eq!(events[1].status.as_deref(), Some("Upcoming"));
401    }
402
403    #[test]
404    fn deserialize_ctf_event_detail_fixture() {
405        let json = include_str!("../../tests/fixtures/ctf/event-detail.json");
406        let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
407        assert_eq!(detail.id, 1434);
408        assert_eq!(detail.players_joined, Some(1200));
409        assert_eq!(detail.challenges, Some(50));
410    }
411
412    #[test]
413    fn deserialize_ctf_event_data_fixture() {
414        let json = include_str!("../../tests/fixtures/ctf/event-data.json");
415        let data: CtfEventData = serde_json::from_str(json).unwrap();
416        assert_eq!(data.challenges.len(), 2);
417        assert_eq!(data.challenges[0].name, "An unusual sighting");
418        assert!(data.challenges[0].solved);
419        assert!(!data.challenges[1].solved);
420        assert_eq!(data.challenges[1].has_docker, Some(1));
421        let team = data.participating_team.unwrap();
422        assert_eq!(team.rank, Some(42));
423    }
424
425    #[test]
426    fn deserialize_ctf_scoreboard_fixture() {
427        let json = include_str!("../../tests/fixtures/ctf/scoreboard.json");
428        let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
429        assert_eq!(sb.scores.len(), 2);
430        assert_eq!(sb.scores[0].points, Some(15000));
431        let team = sb.participating_team.unwrap();
432        assert_eq!(team.position, Some(42));
433    }
434
435    #[test]
436    fn deserialize_ctf_solves_fixture() {
437        let json = include_str!("../../tests/fixtures/ctf/solves.json");
438        let solves: Vec<CtfSolve> = serde_json::from_str(json).unwrap();
439        assert_eq!(solves.len(), 2);
440        assert_eq!(solves[0].challenge_name.as_deref(), Some("LootStash"));
441    }
442
443    #[test]
444    fn deserialize_ctf_user_profile_fixture() {
445        let json = include_str!("../../tests/fixtures/ctf/user-profile.json");
446        let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
447        assert_eq!(profile.name, "LANGSOMT");
448        assert!(profile.has_any_team);
449    }
450
451    #[test]
452    fn deserialize_ctf_menu_fixture() {
453        let json = include_str!("../../tests/fixtures/ctf/menu.json");
454        let menu: CtfMenu = serde_json::from_str(json).unwrap();
455        assert_eq!(menu.id, 1434);
456        assert_eq!(menu.user_can_view_scoreboard, Some(1));
457    }
458
459    #[test]
460    fn deserialize_ctf_event() {
461        let json = r#"{
462            "id": 1434,
463            "name": "CTF Try Out",
464            "slug": "ctf-try-out-1434",
465            "status": "Ongoing",
466            "starts_at": "2026-01-01T00:00:00.000000Z",
467            "ends_at": "2026-12-31T12:00:00.000000Z",
468            "format": "Jeopardy",
469            "team_mode": "user_organized",
470            "team_size": 5,
471            "players": 1200,
472            "private": 0,
473            "university": false,
474            "isBusiness": false,
475            "featured": false,
476            "banner": "https://example.com/banner.png",
477            "org_name": "Hack The Box",
478            "org_logo": "https://example.com/logo.png",
479            "canPlay": true,
480            "canJoin": true,
481            "hasJoined": 1,
482            "joinedTeam": "v1olet",
483            "membersJoined": "4/5",
484            "hide_scoreboard": 0,
485            "mcp_access_mode": "no_mcp"
486        }"#;
487        let event: CtfEvent = serde_json::from_str(json).unwrap();
488        assert_eq!(event.id, 1434);
489        assert_eq!(event.name, "CTF Try Out");
490        assert_eq!(event.status.as_deref(), Some("Ongoing"));
491        assert_eq!(event.has_joined, 1);
492        assert_eq!(event.joined_team.as_deref(), Some("v1olet"));
493    }
494
495    #[test]
496    fn deserialize_ctf_event_detail() {
497        let json = r#"{
498            "id": 1434,
499            "name": "CTF Try Out",
500            "slug": "ctf-try-out-1434",
501            "status": "Ongoing",
502            "startDate": "01 Jan 2026, 00:00 AM UTC",
503            "endDate": "31 Dec 2026, 12:00 PM UTC",
504            "description": "<p>A practice CTF</p>",
505            "type": "Public",
506            "format": "Jeopardy",
507            "location": "Online",
508            "playersJoined": 1200,
509            "teamsJoined": 300,
510            "challenges": 50,
511            "challengeCategories": 10,
512            "maxTeamSize": 5,
513            "prizePool": null,
514            "featured": false,
515            "ai_usage_policy": null
516        }"#;
517        let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
518        assert_eq!(detail.id, 1434);
519        assert_eq!(detail.players_joined, Some(1200));
520        assert_eq!(detail.teams_joined, Some(300));
521        assert_eq!(detail.challenges, Some(50));
522    }
523
524    #[test]
525    fn deserialize_ctf_event_data_with_challenges() {
526        let json = r#"{
527            "id": 1434,
528            "name": "CTF Try Out",
529            "status": "Ongoing",
530            "hide_scoreboard": 0,
531            "participating_team": {
532                "id": 321013,
533                "name": "v1olet",
534                "points": 5000,
535                "solved_challenges": 10,
536                "total_challenges": 50,
537                "owned_flags": 10,
538                "total_flags": 60,
539                "rank": 42,
540                "isCaptain": false
541            },
542            "challenges": [{
543                "id": 31855,
544                "name": "Test Challenge",
545                "creator": "author",
546                "description": "A test",
547                "challenge_category_id": 2,
548                "difficulty": "Easy",
549                "filename": "test.zip",
550                "hasDocker": 1,
551                "docker_online": null,
552                "docker_ports": null,
553                "points": 500,
554                "solves": 42,
555                "solved": true,
556                "status": "in progress",
557                "flagsInfo": [
558                    {"flag_id": 1, "identifier": null, "question": null, "solved": true}
559                ],
560                "machine": null,
561                "team_solves": []
562            }]
563        }"#;
564        let data: CtfEventData = serde_json::from_str(json).unwrap();
565        assert_eq!(data.id, 1434);
566        assert_eq!(data.challenges.len(), 1);
567        assert_eq!(data.challenges[0].name, "Test Challenge");
568        assert!(data.challenges[0].solved);
569        assert_eq!(data.challenges[0].flags_info.len(), 1);
570        let team = data.participating_team.unwrap();
571        assert_eq!(team.rank, Some(42));
572        assert_eq!(team.points, Some(5000));
573    }
574
575    #[test]
576    fn deserialize_ctf_scoreboard() {
577        let json = r#"{
578            "ctf_name": "CTF Try Out",
579            "ctf_teams": 300,
580            "ctf_players": 1200,
581            "is_ended": false,
582            "participating_team": {
583                "id": 321013,
584                "name": "v1olet",
585                "position": 42,
586                "points": 5000,
587                "solved_challenges": 10,
588                "total_challenges": 50,
589                "owned_flags": 10,
590                "first_bloods": 2
591            },
592            "scores": [{
593                "id": 1,
594                "name": "Top Team",
595                "country_code": "US",
596                "points": 10000,
597                "owned_flags": 50,
598                "first_bloods": 5
599            }]
600        }"#;
601        let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
602        assert_eq!(sb.ctf_teams, Some(300));
603        assert_eq!(sb.scores.len(), 1);
604        assert_eq!(sb.scores[0].points, Some(10000));
605        let team = sb.participating_team.unwrap();
606        assert_eq!(team.position, Some(42));
607    }
608
609    #[test]
610    fn deserialize_ctf_solve() {
611        let json = r#"{
612            "challenge_name": "LootStash",
613            "challenge_category": "Reversing",
614            "team_name": "v1olet",
615            "user_name": "LANGSOMT",
616            "created_at": "2026-07-22T11:12:05.000000Z"
617        }"#;
618        let solve: CtfSolve = serde_json::from_str(json).unwrap();
619        assert_eq!(solve.challenge_name.as_deref(), Some("LootStash"));
620        assert_eq!(solve.team_name.as_deref(), Some("v1olet"));
621    }
622
623    #[test]
624    fn deserialize_ctf_flag_result() {
625        let json = r#"{"message": "Correct flag!", "points": 725}"#;
626        let result: CtfFlagResult = serde_json::from_str(json).unwrap();
627        assert_eq!(result.message, "Correct flag!");
628        assert_eq!(result.points, Some(725));
629    }
630
631    #[test]
632    fn deserialize_ctf_user_profile() {
633        let json = r#"{
634            "id": 1084837,
635            "name": "LANGSOMT",
636            "full_name": "Allan Kimmer Jensen",
637            "email": "test@example.com",
638            "timezone": "Europe/London",
639            "hasAnyTeam": true,
640            "hasNormalTeam": true,
641            "avatar": null,
642            "device_id": "abc"
643        }"#;
644        let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
645        assert_eq!(profile.id, 1084837);
646        assert_eq!(profile.name, "LANGSOMT");
647        assert!(profile.has_any_team);
648    }
649}