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: Option<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.unwrap_or(0) > 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: Option<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<u32>,
184    #[serde(default)]
185    pub docker_ports: Option<Vec<u32>>,
186    #[serde(default)]
187    pub hostname: Option<String>,
188    #[serde(default)]
189    pub points: Option<u32>,
190    #[serde(default)]
191    pub solves: Option<u32>,
192    #[serde(default)]
193    pub solved: bool,
194    #[serde(default)]
195    pub status: Option<String>,
196    #[serde(default, rename = "flagsInfo")]
197    pub flags_info: Vec<CtfFlagInfo>,
198}
199
200impl Tabular for CtfChallenge {
201    fn headers() -> Vec<&'static str> {
202        vec![
203            "ID",
204            "Name",
205            "Difficulty",
206            "Points",
207            "Solves",
208            "Docker",
209            "Download",
210            "Flags",
211            "Solved",
212        ]
213    }
214
215    fn row(&self) -> Vec<String> {
216        vec![
217            self.id.to_string(),
218            self.name.clone(),
219            self.difficulty.clone().unwrap_or_default(),
220            self.points.map(|p| p.to_string()).unwrap_or_default(),
221            self.solves.map(|s| s.to_string()).unwrap_or_default(),
222            if self.has_docker.unwrap_or(0) > 0 {
223                "Yes"
224            } else {
225                ""
226            }
227            .to_string(),
228            if self.filename.is_some() { "Yes" } else { "" }.to_string(),
229            self.flags_info.len().to_string(),
230            if self.solved { "Yes" } else { "" }.to_string(),
231        ]
232    }
233}
234
235#[derive(Debug, Deserialize, Serialize, Clone)]
236pub struct CtfFlagInfo {
237    pub flag_id: u64,
238    #[serde(default)]
239    pub identifier: Option<String>,
240    #[serde(default)]
241    pub question: Option<String>,
242    #[serde(default)]
243    pub solved: bool,
244}
245
246#[derive(Debug, Deserialize, Serialize)]
247pub struct CtfMenu {
248    pub id: u64,
249    #[serde(default, rename = "userCanViewScoreboard")]
250    pub user_can_view_scoreboard: Option<u32>,
251    #[serde(default)]
252    pub status: Option<String>,
253}
254
255#[derive(Debug, Deserialize, Serialize)]
256pub struct CtfScoreboard {
257    #[serde(default)]
258    pub ctf_name: Option<String>,
259    #[serde(default)]
260    pub ctf_teams: Option<u32>,
261    #[serde(default)]
262    pub ctf_players: Option<u32>,
263    #[serde(default)]
264    pub is_ended: bool,
265    #[serde(default)]
266    pub participating_team: Option<CtfScoreboardTeam>,
267    #[serde(default)]
268    pub scores: Vec<CtfTeamScore>,
269}
270
271#[derive(Debug, Deserialize, Serialize)]
272pub struct CtfScoreboardTeam {
273    pub id: u64,
274    pub name: String,
275    #[serde(default)]
276    pub position: Option<u32>,
277    #[serde(default)]
278    pub points: Option<u32>,
279    #[serde(default)]
280    pub solved_challenges: Option<u32>,
281    #[serde(default)]
282    pub total_challenges: Option<u32>,
283    #[serde(default)]
284    pub owned_flags: Option<u32>,
285    #[serde(default)]
286    pub first_bloods: Option<u32>,
287}
288
289#[derive(Debug, Deserialize, Serialize)]
290pub struct CtfTeamScore {
291    pub id: u64,
292    pub name: String,
293    #[serde(default)]
294    pub country_code: Option<String>,
295    #[serde(default)]
296    pub points: Option<u32>,
297    #[serde(default)]
298    pub owned_flags: Option<u32>,
299    #[serde(default)]
300    pub first_bloods: Option<u32>,
301}
302
303impl Tabular for CtfTeamScore {
304    fn headers() -> Vec<&'static str> {
305        vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
306    }
307
308    fn row(&self) -> Vec<String> {
309        vec![
310            String::new(), // rank filled by display
311            self.name.clone(),
312            self.country_code.clone().unwrap_or_default(),
313            self.points.map(|p| p.to_string()).unwrap_or_default(),
314            self.owned_flags.map(|f| f.to_string()).unwrap_or_default(),
315            self.first_bloods.map(|b| b.to_string()).unwrap_or_default(),
316        ]
317    }
318}
319
320#[derive(Debug, Deserialize, Serialize)]
321pub struct CtfSolve {
322    #[serde(default)]
323    pub challenge_name: Option<String>,
324    #[serde(default)]
325    pub challenge_category: Option<String>,
326    #[serde(default)]
327    pub team_name: Option<String>,
328    #[serde(default)]
329    pub user_name: Option<String>,
330    #[serde(default)]
331    pub created_at: Option<String>,
332}
333
334impl Tabular for CtfSolve {
335    fn headers() -> Vec<&'static str> {
336        vec!["Challenge", "Category", "Team", "Player", "Time"]
337    }
338
339    fn row(&self) -> Vec<String> {
340        vec![
341            self.challenge_name.clone().unwrap_or_default(),
342            self.challenge_category.clone().unwrap_or_default(),
343            self.team_name.clone().unwrap_or_default(),
344            self.user_name.clone().unwrap_or_default(),
345            self.created_at.clone().unwrap_or_default(),
346        ]
347    }
348}
349
350#[derive(Debug, Deserialize, Serialize)]
351pub struct CtfChallengeSolve {
352    #[serde(default)]
353    pub team_id: Option<u64>,
354    #[serde(default)]
355    pub team_name: Option<String>,
356    #[serde(default)]
357    pub date_of_latest_own: Option<String>,
358    #[serde(default)]
359    pub user_of_latest_own: Option<CtfSolveUser>,
360}
361
362#[derive(Debug, Deserialize, Serialize)]
363pub struct CtfSolveUser {
364    pub name: String,
365}
366
367impl Tabular for CtfChallengeSolve {
368    fn headers() -> Vec<&'static str> {
369        vec!["Team", "Player", "Time"]
370    }
371
372    fn row(&self) -> Vec<String> {
373        vec![
374            self.team_name.clone().unwrap_or_default(),
375            self.user_of_latest_own
376                .as_ref()
377                .map(|u| u.name.clone())
378                .unwrap_or_default(),
379            self.date_of_latest_own.clone().unwrap_or_default(),
380        ]
381    }
382}
383
384#[derive(Debug, Deserialize, Serialize)]
385pub struct CtfFlagResult {
386    pub message: String,
387    #[serde(default)]
388    pub points: Option<u32>,
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn deserialize_ctf_events_fixture() {
397        let json = include_str!("../../tests/fixtures/ctf/events.json");
398        let events: Vec<CtfEvent> = serde_json::from_str(json).unwrap();
399        assert_eq!(events.len(), 2);
400        assert_eq!(events[0].name, "CTF Try Out");
401        assert_eq!(events[0].has_joined, Some(1));
402        assert_eq!(events[1].status.as_deref(), Some("Upcoming"));
403    }
404
405    #[test]
406    fn deserialize_ctf_event_detail_fixture() {
407        let json = include_str!("../../tests/fixtures/ctf/event-detail.json");
408        let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
409        assert_eq!(detail.id, 1434);
410        assert_eq!(detail.players_joined, Some(1200));
411        assert_eq!(detail.challenges, Some(50));
412    }
413
414    #[test]
415    fn deserialize_ctf_event_data_fixture() {
416        let json = include_str!("../../tests/fixtures/ctf/event-data.json");
417        let data: CtfEventData = serde_json::from_str(json).unwrap();
418        assert_eq!(data.challenges.len(), 2);
419        assert_eq!(data.challenges[0].name, "Welcome to CTF");
420        assert!(data.challenges[0].solved);
421        assert!(!data.challenges[1].solved);
422        assert_eq!(data.challenges[1].has_docker, Some(1));
423        assert_eq!(data.challenges[1].docker_online, Some(1));
424        assert_eq!(data.challenges[1].docker_ports, Some(vec![30417]));
425        assert_eq!(
426            data.challenges[1].hostname.as_deref(),
427            Some("154.57.164.83")
428        );
429        let team = data.participating_team.unwrap();
430        assert_eq!(team.rank, Some(116));
431    }
432
433    #[test]
434    fn deserialize_ctf_scoreboard_fixture() {
435        let json = include_str!("../../tests/fixtures/ctf/scoreboard.json");
436        let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
437        assert_eq!(sb.scores.len(), 2);
438        assert_eq!(sb.scores[0].points, Some(15000));
439        let team = sb.participating_team.unwrap();
440        assert_eq!(team.position, Some(42));
441    }
442
443    #[test]
444    fn deserialize_ctf_solves_fixture() {
445        let json = include_str!("../../tests/fixtures/ctf/solves.json");
446        let solves: Vec<CtfSolve> = serde_json::from_str(json).unwrap();
447        assert_eq!(solves.len(), 2);
448        assert_eq!(solves[0].challenge_name.as_deref(), Some("LootStash"));
449    }
450
451    #[test]
452    fn deserialize_ctf_user_profile_fixture() {
453        let json = include_str!("../../tests/fixtures/ctf/user-profile.json");
454        let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
455        assert_eq!(profile.name, "LANGSOMT");
456        assert!(profile.has_any_team);
457    }
458
459    #[test]
460    fn deserialize_ctf_challenge_solves_fixture() {
461        let json = include_str!("../../tests/fixtures/ctf/challenge-solves.json");
462        let solves: Vec<CtfChallengeSolve> = serde_json::from_str(json).unwrap();
463        assert_eq!(solves.len(), 2);
464        assert_eq!(solves[0].team_name.as_deref(), Some("The Shadowers"));
465        assert_eq!(
466            solves[1].user_of_latest_own.as_ref().unwrap().name,
467            "LANGSOMT"
468        );
469    }
470
471    #[test]
472    fn deserialize_ctf_menu_fixture() {
473        let json = include_str!("../../tests/fixtures/ctf/menu.json");
474        let menu: CtfMenu = serde_json::from_str(json).unwrap();
475        assert_eq!(menu.id, 1434);
476        assert_eq!(menu.user_can_view_scoreboard, Some(1));
477    }
478
479    #[test]
480    fn deserialize_ctf_event() {
481        let json = r#"{
482            "id": 1434,
483            "name": "CTF Try Out",
484            "slug": "ctf-try-out-1434",
485            "status": "Ongoing",
486            "starts_at": "2026-01-01T00:00:00.000000Z",
487            "ends_at": "2026-12-31T12:00:00.000000Z",
488            "format": "Jeopardy",
489            "team_mode": "user_organized",
490            "team_size": 5,
491            "players": 1200,
492            "private": 0,
493            "university": false,
494            "isBusiness": false,
495            "featured": false,
496            "banner": "https://example.com/banner.png",
497            "org_name": "Hack The Box",
498            "org_logo": "https://example.com/logo.png",
499            "canPlay": true,
500            "canJoin": true,
501            "hasJoined": 1,
502            "joinedTeam": "v1olet",
503            "membersJoined": "4/5",
504            "hide_scoreboard": 0,
505            "mcp_access_mode": "no_mcp"
506        }"#;
507        let event: CtfEvent = serde_json::from_str(json).unwrap();
508        assert_eq!(event.id, 1434);
509        assert_eq!(event.name, "CTF Try Out");
510        assert_eq!(event.status.as_deref(), Some("Ongoing"));
511        assert_eq!(event.has_joined, Some(1));
512        assert_eq!(event.joined_team.as_deref(), Some("v1olet"));
513    }
514
515    #[test]
516    fn deserialize_ctf_event_detail() {
517        let json = r#"{
518            "id": 1434,
519            "name": "CTF Try Out",
520            "slug": "ctf-try-out-1434",
521            "status": "Ongoing",
522            "startDate": "01 Jan 2026, 00:00 AM UTC",
523            "endDate": "31 Dec 2026, 12:00 PM UTC",
524            "description": "<p>A practice CTF</p>",
525            "type": "Public",
526            "format": "Jeopardy",
527            "location": "Online",
528            "playersJoined": 1200,
529            "teamsJoined": 300,
530            "challenges": 50,
531            "challengeCategories": 10,
532            "maxTeamSize": 5,
533            "prizePool": null,
534            "featured": false,
535            "ai_usage_policy": null
536        }"#;
537        let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
538        assert_eq!(detail.id, 1434);
539        assert_eq!(detail.players_joined, Some(1200));
540        assert_eq!(detail.teams_joined, Some(300));
541        assert_eq!(detail.challenges, Some(50));
542    }
543
544    #[test]
545    fn deserialize_ctf_event_data_with_challenges() {
546        let json = r#"{
547            "id": 1434,
548            "name": "CTF Try Out",
549            "status": "Ongoing",
550            "hide_scoreboard": 0,
551            "participating_team": {
552                "id": 321013,
553                "name": "v1olet",
554                "points": 5000,
555                "solved_challenges": 10,
556                "total_challenges": 50,
557                "owned_flags": 10,
558                "total_flags": 60,
559                "rank": 42,
560                "isCaptain": false
561            },
562            "challenges": [{
563                "id": 31855,
564                "name": "Test Challenge",
565                "creator": "author",
566                "description": "A test",
567                "challenge_category_id": 2,
568                "difficulty": "Easy",
569                "filename": "test.zip",
570                "hasDocker": 1,
571                "docker_online": null,
572                "docker_ports": null,
573                "points": 500,
574                "solves": 42,
575                "solved": true,
576                "status": "in progress",
577                "flagsInfo": [
578                    {"flag_id": 1, "identifier": null, "question": null, "solved": true}
579                ],
580                "machine": null,
581                "team_solves": []
582            }]
583        }"#;
584        let data: CtfEventData = serde_json::from_str(json).unwrap();
585        assert_eq!(data.id, 1434);
586        assert_eq!(data.challenges.len(), 1);
587        assert_eq!(data.challenges[0].name, "Test Challenge");
588        assert!(data.challenges[0].solved);
589        assert_eq!(data.challenges[0].flags_info.len(), 1);
590        let team = data.participating_team.unwrap();
591        assert_eq!(team.rank, Some(42));
592        assert_eq!(team.points, Some(5000));
593    }
594
595    #[test]
596    fn deserialize_ctf_scoreboard() {
597        let json = r#"{
598            "ctf_name": "CTF Try Out",
599            "ctf_teams": 300,
600            "ctf_players": 1200,
601            "is_ended": false,
602            "participating_team": {
603                "id": 321013,
604                "name": "v1olet",
605                "position": 42,
606                "points": 5000,
607                "solved_challenges": 10,
608                "total_challenges": 50,
609                "owned_flags": 10,
610                "first_bloods": 2
611            },
612            "scores": [{
613                "id": 1,
614                "name": "Top Team",
615                "country_code": "US",
616                "points": 10000,
617                "owned_flags": 50,
618                "first_bloods": 5
619            }]
620        }"#;
621        let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
622        assert_eq!(sb.ctf_teams, Some(300));
623        assert_eq!(sb.scores.len(), 1);
624        assert_eq!(sb.scores[0].points, Some(10000));
625        let team = sb.participating_team.unwrap();
626        assert_eq!(team.position, Some(42));
627    }
628
629    #[test]
630    fn deserialize_ctf_solve() {
631        let json = r#"{
632            "challenge_name": "LootStash",
633            "challenge_category": "Reversing",
634            "team_name": "v1olet",
635            "user_name": "LANGSOMT",
636            "created_at": "2026-07-22T11:12:05.000000Z"
637        }"#;
638        let solve: CtfSolve = serde_json::from_str(json).unwrap();
639        assert_eq!(solve.challenge_name.as_deref(), Some("LootStash"));
640        assert_eq!(solve.team_name.as_deref(), Some("v1olet"));
641    }
642
643    #[test]
644    fn deserialize_ctf_flag_result() {
645        let json = r#"{"message": "Correct flag!", "points": 725}"#;
646        let result: CtfFlagResult = serde_json::from_str(json).unwrap();
647        assert_eq!(result.message, "Correct flag!");
648        assert_eq!(result.points, Some(725));
649    }
650
651    #[test]
652    fn deserialize_ctf_user_profile() {
653        let json = r#"{
654            "id": 1084837,
655            "name": "LANGSOMT",
656            "full_name": "Allan Kimmer Jensen",
657            "email": "test@example.com",
658            "timezone": "Europe/London",
659            "hasAnyTeam": true,
660            "hasNormalTeam": true,
661            "avatar": null,
662            "device_id": "abc"
663        }"#;
664        let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
665        assert_eq!(profile.id, 1084837);
666        assert_eq!(profile.name, "LANGSOMT");
667        assert!(profile.has_any_team);
668    }
669}