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#[derive(Debug, Deserialize, Serialize)]
392pub struct CtfTeamOverview {
393    pub id: u64,
394    pub name: String,
395    #[serde(default)]
396    pub members: Vec<CtfTeamMember>,
397}
398
399#[derive(Debug, Deserialize, Serialize)]
400pub struct CtfTeamMember {
401    pub id: u64,
402    pub name: String,
403    #[serde(default)]
404    pub role: Option<String>,
405    #[serde(default)]
406    pub timezone: Option<String>,
407    #[serde(default)]
408    pub flags: Option<u32>,
409    #[serde(default)]
410    pub games: Option<u32>,
411}
412
413impl Tabular for CtfTeamMember {
414    fn headers() -> Vec<&'static str> {
415        vec!["ID", "Name", "Role", "Timezone", "Flags"]
416    }
417
418    fn row(&self) -> Vec<String> {
419        vec![
420            self.id.to_string(),
421            self.name.clone(),
422            self.role.clone().unwrap_or_default(),
423            self.timezone.clone().unwrap_or_default(),
424            self.flags.map(|f| f.to_string()).unwrap_or_default(),
425        ]
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn deserialize_ctf_events_fixture() {
435        let json = include_str!("../../tests/fixtures/ctf/events.json");
436        let events: Vec<CtfEvent> = serde_json::from_str(json).unwrap();
437        assert_eq!(events.len(), 2);
438        assert_eq!(events[0].name, "CTF Try Out");
439        assert_eq!(events[0].has_joined, Some(1));
440        assert_eq!(events[1].status.as_deref(), Some("Upcoming"));
441    }
442
443    #[test]
444    fn deserialize_ctf_event_detail_fixture() {
445        let json = include_str!("../../tests/fixtures/ctf/event-detail.json");
446        let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
447        assert_eq!(detail.id, 1434);
448        assert_eq!(detail.players_joined, Some(1200));
449        assert_eq!(detail.challenges, Some(50));
450    }
451
452    #[test]
453    fn deserialize_ctf_event_data_fixture() {
454        let json = include_str!("../../tests/fixtures/ctf/event-data.json");
455        let data: CtfEventData = serde_json::from_str(json).unwrap();
456        assert_eq!(data.challenges.len(), 2);
457        assert_eq!(data.challenges[0].name, "Welcome to CTF");
458        assert!(data.challenges[0].solved);
459        assert!(!data.challenges[1].solved);
460        assert_eq!(data.challenges[1].has_docker, Some(1));
461        assert_eq!(data.challenges[1].docker_online, Some(1));
462        assert_eq!(data.challenges[1].docker_ports, Some(vec![30417]));
463        assert_eq!(
464            data.challenges[1].hostname.as_deref(),
465            Some("154.57.164.83")
466        );
467        let team = data.participating_team.unwrap();
468        assert_eq!(team.rank, Some(116));
469    }
470
471    #[test]
472    fn deserialize_ctf_scoreboard_fixture() {
473        let json = include_str!("../../tests/fixtures/ctf/scoreboard.json");
474        let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
475        assert_eq!(sb.scores.len(), 2);
476        assert_eq!(sb.scores[0].points, Some(15000));
477        let team = sb.participating_team.unwrap();
478        assert_eq!(team.position, Some(42));
479    }
480
481    #[test]
482    fn deserialize_ctf_solves_fixture() {
483        let json = include_str!("../../tests/fixtures/ctf/solves.json");
484        let solves: Vec<CtfSolve> = serde_json::from_str(json).unwrap();
485        assert_eq!(solves.len(), 2);
486        assert_eq!(solves[0].challenge_name.as_deref(), Some("LootStash"));
487    }
488
489    #[test]
490    fn deserialize_ctf_user_profile_fixture() {
491        let json = include_str!("../../tests/fixtures/ctf/user-profile.json");
492        let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
493        assert_eq!(profile.name, "LANGSOMT");
494        assert!(profile.has_any_team);
495    }
496
497    #[test]
498    fn deserialize_ctf_challenge_solves_fixture() {
499        let json = include_str!("../../tests/fixtures/ctf/challenge-solves.json");
500        let solves: Vec<CtfChallengeSolve> = serde_json::from_str(json).unwrap();
501        assert_eq!(solves.len(), 2);
502        assert_eq!(solves[0].team_name.as_deref(), Some("The Shadowers"));
503        assert_eq!(
504            solves[1].user_of_latest_own.as_ref().unwrap().name,
505            "LANGSOMT"
506        );
507    }
508
509    #[test]
510    fn deserialize_ctf_menu_fixture() {
511        let json = include_str!("../../tests/fixtures/ctf/menu.json");
512        let menu: CtfMenu = serde_json::from_str(json).unwrap();
513        assert_eq!(menu.id, 1434);
514        assert_eq!(menu.user_can_view_scoreboard, Some(1));
515    }
516
517    #[test]
518    fn deserialize_ctf_event() {
519        let json = r#"{
520            "id": 1434,
521            "name": "CTF Try Out",
522            "slug": "ctf-try-out-1434",
523            "status": "Ongoing",
524            "starts_at": "2026-01-01T00:00:00.000000Z",
525            "ends_at": "2026-12-31T12:00:00.000000Z",
526            "format": "Jeopardy",
527            "team_mode": "user_organized",
528            "team_size": 5,
529            "players": 1200,
530            "private": 0,
531            "university": false,
532            "isBusiness": false,
533            "featured": false,
534            "banner": "https://example.com/banner.png",
535            "org_name": "Hack The Box",
536            "org_logo": "https://example.com/logo.png",
537            "canPlay": true,
538            "canJoin": true,
539            "hasJoined": 1,
540            "joinedTeam": "v1olet",
541            "membersJoined": "4/5",
542            "hide_scoreboard": 0,
543            "mcp_access_mode": "no_mcp"
544        }"#;
545        let event: CtfEvent = serde_json::from_str(json).unwrap();
546        assert_eq!(event.id, 1434);
547        assert_eq!(event.name, "CTF Try Out");
548        assert_eq!(event.status.as_deref(), Some("Ongoing"));
549        assert_eq!(event.has_joined, Some(1));
550        assert_eq!(event.joined_team.as_deref(), Some("v1olet"));
551    }
552
553    #[test]
554    fn deserialize_ctf_event_detail() {
555        let json = r#"{
556            "id": 1434,
557            "name": "CTF Try Out",
558            "slug": "ctf-try-out-1434",
559            "status": "Ongoing",
560            "startDate": "01 Jan 2026, 00:00 AM UTC",
561            "endDate": "31 Dec 2026, 12:00 PM UTC",
562            "description": "<p>A practice CTF</p>",
563            "type": "Public",
564            "format": "Jeopardy",
565            "location": "Online",
566            "playersJoined": 1200,
567            "teamsJoined": 300,
568            "challenges": 50,
569            "challengeCategories": 10,
570            "maxTeamSize": 5,
571            "prizePool": null,
572            "featured": false,
573            "ai_usage_policy": null
574        }"#;
575        let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
576        assert_eq!(detail.id, 1434);
577        assert_eq!(detail.players_joined, Some(1200));
578        assert_eq!(detail.teams_joined, Some(300));
579        assert_eq!(detail.challenges, Some(50));
580    }
581
582    #[test]
583    fn deserialize_ctf_event_data_with_challenges() {
584        let json = r#"{
585            "id": 1434,
586            "name": "CTF Try Out",
587            "status": "Ongoing",
588            "hide_scoreboard": 0,
589            "participating_team": {
590                "id": 321013,
591                "name": "v1olet",
592                "points": 5000,
593                "solved_challenges": 10,
594                "total_challenges": 50,
595                "owned_flags": 10,
596                "total_flags": 60,
597                "rank": 42,
598                "isCaptain": false
599            },
600            "challenges": [{
601                "id": 31855,
602                "name": "Test Challenge",
603                "creator": "author",
604                "description": "A test",
605                "challenge_category_id": 2,
606                "difficulty": "Easy",
607                "filename": "test.zip",
608                "hasDocker": 1,
609                "docker_online": null,
610                "docker_ports": null,
611                "points": 500,
612                "solves": 42,
613                "solved": true,
614                "status": "in progress",
615                "flagsInfo": [
616                    {"flag_id": 1, "identifier": null, "question": null, "solved": true}
617                ],
618                "machine": null,
619                "team_solves": []
620            }]
621        }"#;
622        let data: CtfEventData = serde_json::from_str(json).unwrap();
623        assert_eq!(data.id, 1434);
624        assert_eq!(data.challenges.len(), 1);
625        assert_eq!(data.challenges[0].name, "Test Challenge");
626        assert!(data.challenges[0].solved);
627        assert_eq!(data.challenges[0].flags_info.len(), 1);
628        let team = data.participating_team.unwrap();
629        assert_eq!(team.rank, Some(42));
630        assert_eq!(team.points, Some(5000));
631    }
632
633    #[test]
634    fn deserialize_ctf_scoreboard() {
635        let json = r#"{
636            "ctf_name": "CTF Try Out",
637            "ctf_teams": 300,
638            "ctf_players": 1200,
639            "is_ended": false,
640            "participating_team": {
641                "id": 321013,
642                "name": "v1olet",
643                "position": 42,
644                "points": 5000,
645                "solved_challenges": 10,
646                "total_challenges": 50,
647                "owned_flags": 10,
648                "first_bloods": 2
649            },
650            "scores": [{
651                "id": 1,
652                "name": "Top Team",
653                "country_code": "US",
654                "points": 10000,
655                "owned_flags": 50,
656                "first_bloods": 5
657            }]
658        }"#;
659        let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
660        assert_eq!(sb.ctf_teams, Some(300));
661        assert_eq!(sb.scores.len(), 1);
662        assert_eq!(sb.scores[0].points, Some(10000));
663        let team = sb.participating_team.unwrap();
664        assert_eq!(team.position, Some(42));
665    }
666
667    #[test]
668    fn deserialize_ctf_solve() {
669        let json = r#"{
670            "challenge_name": "LootStash",
671            "challenge_category": "Reversing",
672            "team_name": "v1olet",
673            "user_name": "LANGSOMT",
674            "created_at": "2026-07-22T11:12:05.000000Z"
675        }"#;
676        let solve: CtfSolve = serde_json::from_str(json).unwrap();
677        assert_eq!(solve.challenge_name.as_deref(), Some("LootStash"));
678        assert_eq!(solve.team_name.as_deref(), Some("v1olet"));
679    }
680
681    #[test]
682    fn deserialize_ctf_flag_result() {
683        let json = r#"{"message": "Correct flag!", "points": 725}"#;
684        let result: CtfFlagResult = serde_json::from_str(json).unwrap();
685        assert_eq!(result.message, "Correct flag!");
686        assert_eq!(result.points, Some(725));
687    }
688
689    #[test]
690    fn deserialize_ctf_user_profile() {
691        let json = r#"{
692            "id": 1084837,
693            "name": "LANGSOMT",
694            "full_name": "Allan Kimmer Jensen",
695            "email": "test@example.com",
696            "timezone": "Europe/London",
697            "hasAnyTeam": true,
698            "hasNormalTeam": true,
699            "avatar": null,
700            "device_id": "abc"
701        }"#;
702        let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
703        assert_eq!(profile.id, 1084837);
704        assert_eq!(profile.name, "LANGSOMT");
705        assert!(profile.has_any_team);
706    }
707}