superkeyloader_lib/
github.rs

1extern crate pretty_env_logger;
2
3use regex::RegexSet;
4
5pub const INVALID_GH_USERNAME: u16 = 1001;
6pub const INVALID_GH_API_RESPONSE: u16 = 1002;
7
8///
9/// GitHub API response parsing struct (REST v3)
10///
11/// [Documentation](https://developer.github.com/v3/users/keys/)
12///
13/// URL: `GET https://api.github.com/user/<USERNAME>/keys`
14///
15/// # Example
16///
17/// ```
18/// use superkeyloader_lib::github::GhKey;
19///
20/// let json_string = r#"
21///   [
22///     {
23///       "id": 12257919,
24///       "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCarT/me5sWxY9Tizc"
25///     },
26///     {
27///       "id": 22932337,
28///       "key": "ssh-rsa AAAAB3N"
29///     }
30///   ]
31/// "#;
32/// let parsed_json = serde_json::from_str(&json_string);
33/// let keys: Vec<GhKey> = parsed_json.unwrap();
34///
35/// assert_eq!(keys[0].id, 12257919);
36/// assert_eq!(keys[1].key, "ssh-rsa AAAAB3N");
37/// ```
38///
39#[derive(Debug, Serialize, Deserialize)]
40pub struct GhKey {
41    pub id: u64,
42    pub key: String,
43}
44
45///
46/// Regex (set) to validate GitHub usernames
47///
48/// Thanks to: https://github.com/shinnn/github-username-regex
49///
50/// # Rules
51///   - Max 39 characters, alphanumerical and '-' (case insensitive)
52///   - Cannot not end with '-'
53///   - Cannot have multiple consecutive hyphens
54///
55/// > with look-arounds a single regex (no set) could be used
56///
57/// TODO: Move to a single regex with look-arounds when will be supported by the standard Rust regex library.
58///
59fn validate_username(username: &str) -> bool {
60    let username_rules = RegexSet::new(vec![
61        r"^([-a-zA-Z\d]){1,39}$",
62        r".*[^-]$",
63        r"^([^-]+|-($[^-]))*$",
64    ])
65    .unwrap();
66
67    let matches: Vec<_> = username_rules.matches(username).into_iter().collect();
68    // If all rules match then the username is valid
69    username_rules.len() == matches.len()
70}
71
72///
73/// Download user's SSH keys from GitHub
74///
75/// Return a vector of `String` containing all the user keys in the exact same order they were send
76/// by the API.
77///
78/// Output keys format is the following:
79/// `<SSH_KEY> from-GH-id-<KEY_ID>`
80///
81/// > `KEY_ID` is the internal GitHub key id.
82///
83/// # Errors
84///
85/// Return the response status code if it's not a 2XX status code.
86/// Return an internal error code:
87///   - `1001` if GitHub username isn't valid
88///     code stored in `INVALID_GH_USERNAME`
89///   - `1002` if GitHub API response could not be parsed
90///     code stored in `INVALID_GH_API_RESPONSE`
91///
92/// # Example
93///
94/// ```
95/// let token: Option<String>;
96/// # token = std::env::var("GITHUB_TOKEN").ok();
97/// use superkeyloader_lib::github::get_keys;
98///
99/// let keys = get_keys("biosan", token).unwrap();
100///
101/// assert!(keys[0].contains(&String::from("ssh")));
102/// assert!(keys[0].contains(&String::from(" from-GH-id-")));
103/// ```
104///
105pub fn get_keys(username: &str, token: Option<String>) -> Result<Vec<String>, u16> {
106    if !validate_username(username) {
107        return Err(INVALID_GH_USERNAME);
108    }
109
110    // TODO: I don't like very much this approach... find a better way
111    #[cfg(not(test))]
112    let gh_api_url: &str = "https://api.github.com";
113    #[cfg(test)]
114    let gh_api_url: &str = &mockito::server_url();
115    debug!("GitHub API base URL: {}", gh_api_url);
116
117    // 1. Make HTTP request
118    // 2. Transmform reponse JSON to an array of keys
119    let url = format!("{}/users/{}/keys", gh_api_url, username);
120    debug!("GitHub API endpoint URL: {}", url);
121
122    let mut request = ureq::get(&url);
123
124    if let Some(oauth_token) = token {
125        request.set("Authorization", format!("token {}", oauth_token).as_ref());
126    }
127
128    let response = request.call();
129
130    if !response.ok() {
131        return Err(response.status());
132    }
133
134    let resp_json = response.into_string().unwrap();
135    let parsed_json = serde_json::from_str(&resp_json);
136
137    if parsed_json.is_err() {
138        return Err(INVALID_GH_API_RESPONSE);
139    }
140
141    let gh_keys: Vec<GhKey> = parsed_json.unwrap();
142
143    let keys = gh_keys
144        .into_iter()
145        .map(|key| format!("{} from-GH-id-{}", key.key, key.id))
146        .collect();
147
148    Ok(keys)
149}
150
151pub mod test_values {
152
153    pub const VALID_USERNAME: &str = "testuser";
154    pub const MISSING_USERNAME: &str = "erruser";
155    pub const INVALID_USERNAME_LENGTH: &str = "user-user-user-user-user-user-user-user-";
156    pub const INVALID_USERNAME_ENDING_HYPHEN: &str = "user-user-";
157    pub const INVALID_USERNAME_CONSEC_HYPHEN: &str = "user--user";
158
159    pub const VALID_3_KEYS_JSON: &str = r#"[
160      {
161        "id": 12257919,
162        "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCarT/me5sWxY9Tizc"
163      },
164      {
165        "id": 22932337,
166        "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC+MxvBji8iUuN2so2"
167      },
168      {
169        "id": 69196823,
170        "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDq/BrJT0c7LSmTRDE"
171      }
172    ]"#;
173
174    pub const EMPTY_JSON: &str = r#"[]"#;
175
176    pub const INVALID_JSON: &str = r#"[
177      {
178        "id": "12257919",
179        "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCarT/me5sWxY9Tizc"
180      },
181      {
182        "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC+MxvBji8iUuN2so2"
183      },
184      {
185        "id": 69196823,
186        "key": 42
187      }
188    ]"#;
189}
190
191#[cfg(test)]
192mod tests {
193
194    use super::test_values::*;
195
196    use mockito::mock;
197
198    #[test]
199    fn test_github_username_validation() {
200        assert_eq!(
201            super::validate_username(&String::from(VALID_USERNAME)),
202            true
203        );
204        assert_eq!(
205            super::validate_username(&String::from(INVALID_USERNAME_LENGTH)),
206            false
207        );
208        assert_eq!(
209            super::validate_username(&String::from(INVALID_USERNAME_ENDING_HYPHEN)),
210            false
211        );
212        assert_eq!(
213            super::validate_username(&String::from(INVALID_USERNAME_CONSEC_HYPHEN)),
214            false
215        );
216    }
217
218    #[test]
219    fn valid_response() {
220        let _m = mock("GET", "/users/testuser/keys")
221            .with_status(200)
222            .with_header("Content-Type", "application/json; charset=utf-8")
223            .with_body(VALID_3_KEYS_JSON)
224            .create();
225
226        let result = super::get_keys(&String::from(VALID_USERNAME), None);
227
228        assert_eq!(result.is_ok(), true);
229        assert_eq!(result.unwrap().len(), 3);
230    }
231
232    #[test]
233    fn invalid_response() {
234        let _m = mock("GET", "/users/testuser/keys")
235            .with_status(200)
236            .with_header("Content-Type", "application/json; charset=utf-8")
237            .with_body(INVALID_JSON)
238            .create();
239
240        let result = super::get_keys(&String::from(VALID_USERNAME), None);
241
242        assert_eq!(result.is_ok(), false);
243        assert_eq!(result.err().unwrap(), super::INVALID_GH_API_RESPONSE);
244    }
245
246    #[test]
247    fn no_keys_response() {
248        let _m = mock("GET", "/users/testuser/keys")
249            .with_status(200)
250            .with_header("Content-Type", "application/json; charset=utf-8")
251            .with_body(EMPTY_JSON)
252            .create();
253
254        let result = super::get_keys(&String::from(VALID_USERNAME), None);
255
256        assert_eq!(result.is_ok(), true);
257        assert_eq!(result.unwrap().len(), 0);
258    }
259
260    #[test]
261    fn missing_username() {
262        let _m = mock("GET", "/users/erruser/keys")
263            .with_status(404)
264            .with_header("Content-Type", "application/json; charset=utf-8")
265            .with_body(VALID_3_KEYS_JSON)
266            .create();
267
268        let result = super::get_keys(&String::from(MISSING_USERNAME), None);
269
270        assert_eq!(result.is_ok(), false);
271        assert_eq!(result.err().unwrap(), 404);
272    }
273
274    #[test]
275    fn invalid_username() {
276        let _m = mock("GET", "/users/testuser/keys")
277            .with_status(200)
278            .with_header("Content-Type", "application/json; charset=utf-8")
279            .with_body(VALID_3_KEYS_JSON)
280            .create();
281
282        // Test 'too long' username case
283        let result = super::get_keys(&String::from(INVALID_USERNAME_LENGTH), None);
284        assert_eq!(result.is_ok(), false);
285        assert_eq!(result.err().unwrap(), super::INVALID_GH_USERNAME);
286
287        // Test 'ending with hyphen' username case
288        let result = super::get_keys(&String::from(INVALID_USERNAME_ENDING_HYPHEN), None);
289        assert_eq!(result.is_ok(), false);
290        assert_eq!(result.err().unwrap(), super::INVALID_GH_USERNAME);
291
292        // Test 'two consecutive' username case
293        let result = super::get_keys(&String::from(INVALID_USERNAME_CONSEC_HYPHEN), None);
294        assert_eq!(result.is_ok(), false);
295        assert_eq!(result.err().unwrap(), super::INVALID_GH_USERNAME);
296    }
297}