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(), 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}