Skip to main content

htb_cli/cli/
challenges.rs

1use clap::Subcommand;
2
3use crate::api::HtbClient;
4use crate::output::{self, OutputFormat};
5
6#[derive(Subcommand)]
7#[command(
8    after_help = "Examples:\n  htb challenges list --category Web     Web challenges\n  htb challenges categories              All categories\n  htb challenges info Poly               Challenge details\n  htb challenges start Poly              Start container instance\n  htb challenges submit 112 'HTB{f}'     Submit a flag\n  htb challenges download Poly           Download challenge files"
9)]
10pub enum ChallengeCommand {
11    /// List challenges
12    List {
13        #[arg(long, help = "Filter by category")]
14        category: Option<String>,
15        #[arg(long, help = "Filter by state (active, retired_free)")]
16        state: Option<String>,
17        #[arg(long, help = "Search by keyword (server-side)")]
18        keyword: Option<String>,
19        #[arg(long, help = "Page number")]
20        page: Option<u32>,
21    },
22    /// List challenge categories
23    Categories,
24    /// Show challenge details
25    Info {
26        /// Challenge slug
27        slug: String,
28    },
29    /// Download challenge files
30    Download {
31        /// Challenge slug
32        slug: String,
33    },
34    /// Start a challenge instance
35    Start {
36        /// Challenge slug
37        slug: String,
38    },
39    /// Stop a challenge instance
40    Stop {
41        /// Challenge slug
42        slug: String,
43    },
44    /// Download official writeup (if available)
45    Writeup {
46        /// Challenge slug
47        slug: String,
48    },
49    /// Submit a flag
50    Submit {
51        /// Challenge ID
52        id: u64,
53        /// The flag
54        flag: String,
55    },
56}
57
58pub async fn handle(
59    client: &HtbClient,
60    cmd: ChallengeCommand,
61    format: OutputFormat,
62) -> anyhow::Result<()> {
63    match cmd {
64        ChallengeCommand::List {
65            category,
66            state,
67            keyword,
68            page,
69        } => {
70            let result = client
71                .challenges()
72                .list(page.unwrap_or(1), 100, keyword.as_deref())
73                .await?;
74            let mut challenges = result.data;
75
76            if let Some(ref cat_filter) = category {
77                challenges.retain(|c| {
78                    c.category_name
79                        .as_ref()
80                        .is_some_and(|cn| cn.eq_ignore_ascii_case(cat_filter))
81                });
82            }
83
84            if let Some(ref state_filter) = state {
85                challenges.retain(|c| {
86                    c.state
87                        .as_ref()
88                        .is_some_and(|s| s.eq_ignore_ascii_case(state_filter))
89                });
90            }
91
92            output::print_list(&challenges, format);
93            output::print_pagination(
94                result.meta.current_page,
95                result.meta.last_page,
96                result.meta.total,
97                format,
98            );
99        }
100
101        ChallengeCommand::Categories => {
102            let categories = client.challenges().categories().await?;
103            output::print_list(&categories, format);
104        }
105
106        ChallengeCommand::Info { slug } => {
107            let detail = client.challenges().info(&slug).await?;
108            let fields = vec![
109                ("ID", detail.id.to_string()),
110                ("Name", detail.name.clone()),
111                ("Difficulty", detail.difficulty.clone().unwrap_or_default()),
112                ("Category", detail.category_name.clone().unwrap_or_default()),
113                (
114                    "Points",
115                    detail.points.clone().unwrap_or_else(|| "0".into()),
116                ),
117                (
118                    "XP",
119                    detail
120                        .experience_points
121                        .map(|xp| xp.to_string())
122                        .unwrap_or_else(|| "0".into()),
123                ),
124                (
125                    "Rating",
126                    detail.stars.map(|s| format!("{s:.1}")).unwrap_or_default(),
127                ),
128                ("Solves", detail.solves.to_string()),
129                ("State", detail.state.clone().unwrap_or_default()),
130                (
131                    "First Blood",
132                    detail
133                        .first_blood_user
134                        .clone()
135                        .unwrap_or_else(|| "-".into()),
136                ),
137                (
138                    "Blood Time",
139                    detail
140                        .first_blood_time
141                        .clone()
142                        .unwrap_or_else(|| "-".into()),
143                ),
144                ("Creator", detail.creator_name.clone().unwrap_or_default()),
145                (
146                    "Description",
147                    detail.description.clone().unwrap_or_default(),
148                ),
149                ("Play Methods", detail.play_methods.join(", ")),
150                (
151                    "Solved",
152                    if detail.auth_user_solve { "Yes" } else { "No" }.into(),
153                ),
154            ];
155            output::print_detail(&detail, format, &fields);
156        }
157
158        ChallengeCommand::Download { slug } => {
159            let detail = client.challenges().info(&slug).await?;
160            let url = client.challenges().download_link(detail.id).await?;
161            let bytes = client.challenges().download_file(&url).await?;
162            let raw_name = detail.file_name.unwrap_or_else(|| format!("{slug}.zip"));
163            let filename = crate::sanitize_filename(&raw_name, &format!("{slug}.zip"));
164            std::fs::write(&filename, &bytes)?;
165            output::print_message(&format!("Downloaded {} ({} bytes)", filename, bytes.len()));
166        }
167
168        ChallengeCommand::Start { slug } => {
169            let detail = client.challenges().info(&slug).await?;
170            let resp = client.challenges().start(detail.id).await?;
171            output::print_message(&resp.message);
172        }
173
174        ChallengeCommand::Stop { slug } => {
175            let detail = client.challenges().info(&slug).await?;
176            let resp = client.challenges().stop(detail.id).await?;
177            output::print_message(&resp.message);
178        }
179
180        ChallengeCommand::Writeup { slug } => {
181            let detail = client.challenges().info(&slug).await?;
182            let writeup = client.challenges().writeup_info(detail.id).await?;
183            match writeup.data.official {
184                Some(w) if w.url.is_some() => {
185                    let bytes = client.challenges().writeup_download(detail.id).await?;
186                    let filename = w.filename.unwrap_or_else(|| format!("{slug}-writeup.pdf"));
187                    let safe_name =
188                        crate::sanitize_filename(&filename, &format!("{slug}-writeup.pdf"));
189                    std::fs::write(&safe_name, &bytes)?;
190                    output::print_message(&format!(
191                        "Downloaded {} ({} bytes)",
192                        safe_name,
193                        bytes.len()
194                    ));
195                }
196                _ => {
197                    output::print_message("No official writeup available for this challenge.");
198                }
199            }
200        }
201
202        ChallengeCommand::Submit { id, flag } => {
203            let resp = client.challenges().submit_flag(id, &flag).await?;
204            output::print_message(&resp.message);
205        }
206    }
207    Ok(())
208}