Skip to main content

hackerone_api/
types.rs

1//! Wire types.
2//!
3//! The HackerOne API speaks a JSON:API-ish dialect: every payload is wrapped
4//! in `data` / `attributes` / `relationships`, with `links` and `meta` beside
5//! it. These types model that envelope generically ([`Resource`],
6//! [`SingleDoc`], [`CollectionDoc`], [`Page`]) and then the domain objects
7//! (reports, programs, scopes, weaknesses, …).
8//!
9//! Domain structs keep the fields the API documents and stash everything else
10//! in a flattened `extra` map, so a server-side field addition never breaks a
11//! decode.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17/// JSON:API `links` object (pagination URLs).
18#[derive(Debug, Clone, Deserialize, Default)]
19pub struct Links {
20    /// Next page URL, when there is one.
21    #[serde(default)]
22    pub next: Option<String>,
23    /// Last page URL.
24    #[serde(default)]
25    pub last: Option<String>,
26    /// Self URL.
27    #[serde(default, rename = "self")]
28    pub this: Option<String>,
29}
30
31/// JSON:API `meta` object, kept loose.
32#[derive(Debug, Clone, Deserialize, Default)]
33pub struct Meta {
34    /// Any keys the API includes.
35    #[serde(flatten)]
36    pub extra: BTreeMap<String, serde_json::Value>,
37}
38
39/// A single JSON:API resource: id + type + attributes.
40#[derive(Debug, Clone, Default, Deserialize)]
41#[serde(bound(deserialize = "A: serde::Deserialize<'de> + Default"))]
42pub struct Resource<A> {
43    /// Resource id.
44    #[serde(default)]
45    pub id: Option<String>,
46    /// Resource type (`"report"`, `"program"`, …).
47    #[serde(default, rename = "type")]
48    pub kind: Option<String>,
49    /// The resource's attributes.
50    #[serde(default)]
51    pub attributes: A,
52    /// Relationships, kept as raw JSON.
53    #[serde(default)]
54    pub relationships: serde_json::Value,
55}
56
57/// A single-object response (`GET /v1/me`, `GET /v1/reports/{id}`, …).
58#[derive(Debug, Clone, Deserialize)]
59#[serde(bound(deserialize = "A: serde::Deserialize<'de> + Default"))]
60pub struct SingleDoc<A> {
61    /// The resource.
62    pub data: Resource<A>,
63    /// Pagination/navigation links.
64    #[serde(default)]
65    pub links: Links,
66    /// Response metadata.
67    #[serde(default)]
68    pub meta: Meta,
69}
70
71/// A collection response (`GET /v1/reports`, `GET /v1/me/programs`, …).
72#[derive(Debug, Clone, Deserialize)]
73#[serde(bound(deserialize = "A: serde::Deserialize<'de> + Default"))]
74pub struct CollectionDoc<A> {
75    /// The resources.
76    #[serde(default)]
77    pub data: Vec<Resource<A>>,
78    /// Pagination links.
79    #[serde(default)]
80    pub links: Links,
81    /// Response metadata.
82    #[serde(default)]
83    pub meta: Meta,
84}
85
86/// A decoded page: the items plus the links needed to fetch more.
87#[derive(Debug, Clone)]
88pub struct Page<A> {
89    /// The page's resources.
90    pub resources: Vec<Resource<A>>,
91    /// URL of the next page, if any.
92    pub next: Option<String>,
93    /// URL of the last page, if any.
94    pub last: Option<String>,
95}
96
97impl<A> Page<A> {
98    /// Build a page from a decoded collection document.
99    pub fn from_doc(doc: CollectionDoc<A>) -> Self {
100        Self {
101            resources: doc.data,
102            next: doc.links.next,
103            last: doc.links.last,
104        }
105    }
106
107    /// Number of items on this page.
108    pub fn len(&self) -> usize {
109        self.resources.len()
110    }
111
112    /// Whether the page is empty.
113    pub fn is_empty(&self) -> bool {
114        self.resources.is_empty()
115    }
116
117    /// The item attributes, in order.
118    pub fn items(&self) -> impl Iterator<Item = &A> {
119        self.resources.iter().map(|r| &r.attributes)
120    }
121
122    /// The item ids, in order.
123    pub fn ids(&self) -> impl Iterator<Item = Option<&str>> {
124        self.resources.iter().map(|r| r.id.as_deref())
125    }
126
127    /// Consume the page into the bare item list.
128    pub fn into_items(self) -> Vec<A> {
129        self.resources.into_iter().map(|r| r.attributes).collect()
130    }
131}
132
133macro_rules! flexible {
134    ($name:ident { $( $field:ident : $ty:ty ),* $(,)? }) => {
135        #[derive(Debug, Clone, Deserialize, Default)]
136        #[doc = concat!("See the HackerOne API reference for `", stringify!($name), "`.")]
137        pub struct $name {
138            $(
139                #[serde(default)]
140                #[doc = concat!("`", stringify!($field), "`")]
141                pub $field: Option<$ty>,
142            )*
143            /// Fields the API returned that this version does not name.
144            #[serde(flatten)]
145            pub extra: BTreeMap<String, serde_json::Value>,
146        }
147    };
148}
149
150flexible!(User {
151    username: String,
152    name: String,
153    email: String,
154    created_at: String,
155    disabled: bool,
156    location: String,
157});
158
159flexible!(Program {
160    handle: String,
161    name: String,
162    state: String,
163    submission_state: String,
164    offers_bounties: bool,
165    policy: String,
166    started_accepting_at: String,
167});
168
169flexible!(StructuredScope {
170    asset_identifier: String,
171    asset_type: String,
172    eligible_for_bounty: bool,
173    eligible_for_submission: bool,
174    max_severity: String,
175    instruction: String,
176    created_at: String,
177});
178
179flexible!(Report {
180    title: String,
181    state: String,
182    created_at: String,
183    updated_at: String,
184    vulnerability_information: String,
185    disclosed_at: String,
186    bounty_awarded_at: String,
187    has_bounty: bool,
188});
189
190flexible!(Weakness {
191    name: String,
192    description: String,
193    external_id: String,
194    created_at: String,
195});
196
197flexible!(Severity {
198    rating: String,
199    score: f64,
200    cvss_vector: String,
201    author_type: String,
202    created_at: String,
203});
204
205/// Severity rating supplied when creating a report.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
207#[serde(rename_all = "lowercase")]
208pub enum SeverityRating {
209    /// `none`
210    None,
211    /// `low`
212    Low,
213    /// `medium`
214    Medium,
215    /// `high`
216    High,
217    /// `critical`
218    Critical,
219}
220
221impl SeverityRating {
222    /// The wire value.
223    pub fn as_str(self) -> &'static str {
224        match self {
225            SeverityRating::None => "none",
226            SeverityRating::Low => "low",
227            SeverityRating::Medium => "medium",
228            SeverityRating::High => "high",
229            SeverityRating::Critical => "critical",
230        }
231    }
232}
233
234/// Report states accepted by a state change.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
236#[serde(rename_all = "snake_case")]
237pub enum ReportState {
238    /// `new`
239    New,
240    /// `triaged`
241    Triaged,
242    /// `needs_more_info`
243    NeedsMoreInfo,
244    /// `resolved`
245    Resolved,
246    /// `informative`
247    Informative,
248    /// `not_applicable`
249    NotApplicable,
250    /// `duplicate`
251    Duplicate,
252    /// `spam`
253    Spam,
254}
255
256impl ReportState {
257    /// The wire value.
258    pub fn as_str(self) -> &'static str {
259        match self {
260            ReportState::New => "new",
261            ReportState::Triaged => "triaged",
262            ReportState::NeedsMoreInfo => "needs_more_info",
263            ReportState::Resolved => "resolved",
264            ReportState::Informative => "informative",
265            ReportState::NotApplicable => "not_applicable",
266            ReportState::Duplicate => "duplicate",
267            ReportState::Spam => "spam",
268        }
269    }
270}
271
272/// A report to create. Build it with the chainable methods, then
273/// [`Client::create_report`](crate::Client::create_report).
274#[derive(Debug, Clone, Default)]
275pub struct CreateReport {
276    /// Program handle or numeric id (required).
277    pub program: String,
278    /// Report title (required).
279    pub title: String,
280    /// The finding write-up.
281    pub vulnerability_information: Option<String>,
282    /// Business impact.
283    pub impact: Option<String>,
284    /// Suggested severity.
285    pub severity_rating: Option<SeverityRating>,
286    /// Weakness id (CWE mapping) — `GET /v1/weaknesses` resolves these.
287    pub weakness_id: Option<String>,
288    /// Structured scope id this report targets.
289    pub structured_scope_id: Option<String>,
290    /// Program-specific custom fields.
291    pub custom_fields: BTreeMap<String, serde_json::Value>,
292}
293
294impl CreateReport {
295    /// A report for `program` with `title`.
296    pub fn new(program: impl Into<String>, title: impl Into<String>) -> Self {
297        Self {
298            program: program.into(),
299            title: title.into(),
300            ..Default::default()
301        }
302    }
303
304    /// Set the vulnerability write-up.
305    pub fn vulnerability_information(mut self, text: impl Into<String>) -> Self {
306        self.vulnerability_information = Some(text.into());
307        self
308    }
309
310    /// Set the impact statement.
311    pub fn impact(mut self, text: impl Into<String>) -> Self {
312        self.impact = Some(text.into());
313        self
314    }
315
316    /// Suggest a severity.
317    pub fn severity(mut self, rating: SeverityRating) -> Self {
318        self.severity_rating = Some(rating);
319        self
320    }
321
322    /// Attach a weakness id.
323    pub fn weakness(mut self, id: impl Into<String>) -> Self {
324        self.weakness_id = Some(id.into());
325        self
326    }
327
328    /// Pin the structured scope.
329    pub fn structured_scope(mut self, id: impl Into<String>) -> Self {
330        self.structured_scope_id = Some(id.into());
331        self
332    }
333
334    /// Set a program-specific custom field.
335    pub fn custom_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
336        self.custom_fields.insert(key.into(), value);
337        self
338    }
339
340    /// Render the JSON:API request body.
341    pub fn to_json(&self) -> Result<serde_json::Value, crate::Error> {
342        if self.program.trim().is_empty() {
343            return Err(crate::Error::Invalid("report.program is required".into()));
344        }
345        if self.title.trim().is_empty() {
346            return Err(crate::Error::Invalid("report.title is required".into()));
347        }
348
349        let mut attributes = serde_json::Map::new();
350        attributes.insert("title".into(), serde_json::json!(self.title));
351        if let Some(v) = &self.vulnerability_information {
352            attributes.insert("vulnerability_information".into(), serde_json::json!(v));
353        }
354        if let Some(v) = &self.impact {
355            attributes.insert("impact".into(), serde_json::json!(v));
356        }
357        if let Some(rating) = self.severity_rating {
358            attributes.insert("severity_rating".into(), serde_json::json!(rating.as_str()));
359        }
360        for (key, value) in &self.custom_fields {
361            attributes.insert(key.clone(), value.clone());
362        }
363
364        let mut relationships = serde_json::Map::new();
365        let program_id = self.program.trim_start_matches('@');
366        relationships.insert(
367            "program".into(),
368            serde_json::json!({ "data": { "type": "program", "id": program_id } }),
369        );
370        if let Some(id) = &self.weakness_id {
371            relationships.insert(
372                "weakness".into(),
373                serde_json::json!({ "data": { "type": "weakness", "id": id } }),
374            );
375        }
376        if let Some(id) = &self.structured_scope_id {
377            relationships.insert(
378                "structured_scope".into(),
379                serde_json::json!({ "data": { "type": "structured-scope", "id": id } }),
380            );
381        }
382
383        Ok(serde_json::json!({
384            "data": {
385                "type": "report",
386                "attributes": serde_json::Value::Object(attributes),
387                "relationships": serde_json::Value::Object(relationships),
388            }
389        }))
390    }
391}
392
393/// Filters for [`Client::reports`](crate::Client::reports).
394#[derive(Debug, Clone, Default)]
395pub struct ReportQuery {
396    /// Restrict to these states, e.g. `["new", "triaged"]`.
397    pub states: Vec<String>,
398    /// Restrict to a program handle.
399    pub program: Option<String>,
400    /// Sort expression, e.g. `-created_at`.
401    pub sort: Option<String>,
402    /// 1-based page number.
403    pub page_number: Option<u32>,
404    /// Page size.
405    pub page_size: Option<u32>,
406    /// Any extra `key=value` pairs, passed through verbatim.
407    pub extra: Vec<(String, String)>,
408}
409
410impl ReportQuery {
411    /// An empty query.
412    pub fn new() -> Self {
413        Self::default()
414    }
415
416    /// Restrict to a state.
417    pub fn state(mut self, state: impl Into<String>) -> Self {
418        self.states.push(state.into());
419        self
420    }
421
422    /// Restrict to a program handle.
423    pub fn program(mut self, handle: impl Into<String>) -> Self {
424        self.program = Some(handle.into());
425        self
426    }
427
428    /// Sort expression (prefix `-` for descending).
429    pub fn sort(mut self, sort: impl Into<String>) -> Self {
430        self.sort = Some(sort.into());
431        self
432    }
433
434    /// Set the page.
435    pub fn page(mut self, number: u32, size: u32) -> Self {
436        self.page_number = Some(number);
437        self.page_size = Some(size);
438        self
439    }
440
441    /// Add a raw filter.
442    pub fn filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
443        self.extra.push((key.into(), value.into()));
444        self
445    }
446
447    /// Render to query pairs, in the HackerOne `filter[…]` / `page[…]` shape.
448    pub fn to_pairs(&self) -> Vec<(String, String)> {
449        let mut pairs = Vec::new();
450        for state in &self.states {
451            pairs.push(("filter[state][]".to_string(), state.clone()));
452        }
453        if let Some(program) = &self.program {
454            pairs.push(("filter[program][]".to_string(), program.clone()));
455        }
456        if let Some(sort) = &self.sort {
457            pairs.push(("sort".to_string(), sort.clone()));
458        }
459        if let Some(n) = self.page_number {
460            pairs.push(("page[number]".to_string(), n.to_string()));
461        }
462        if let Some(s) = self.page_size {
463            pairs.push(("page[size]".to_string(), s.to_string()));
464        }
465        pairs.extend(self.extra.iter().cloned());
466        pairs
467    }
468}