hexpm/
lib.rs

1mod proto;
2
3#[cfg(test)]
4mod tests;
5
6pub mod version;
7
8use crate::proto::{signed::Signed, versions::Versions};
9use bytes::buf::Buf;
10use flate2::read::GzDecoder;
11use http::{Method, StatusCode};
12use lazy_static::lazy_static;
13use prost::Message;
14use regex::Regex;
15use ring::digest::{Context, SHA256};
16use serde::Deserialize;
17use serde_json::json;
18use std::{
19    collections::HashMap,
20    convert::{TryFrom, TryInto},
21    fmt::Display,
22    io::{BufReader, Read},
23};
24use thiserror::Error;
25use version::{Range, Version};
26use x509_parser::prelude::FromDer;
27
28#[derive(Debug, Clone)]
29pub struct Config {
30    /// Defaults to https://hex.pm/api/
31    pub api_base: http::Uri,
32    /// Defaults to https://repo.hex.pm/
33    pub repository_base: http::Uri,
34}
35
36impl Config {
37    pub fn new() -> Self {
38        Self {
39            api_base: http::Uri::from_static("https://hex.pm/api/"),
40            repository_base: http::Uri::from_static("https://repo.hex.pm/"),
41        }
42    }
43
44    fn api_request(
45        &self,
46        method: http::Method,
47        path_suffix: &str,
48        api_key: Option<&str>,
49    ) -> http::request::Builder {
50        make_request(self.api_base.clone(), method, path_suffix, api_key)
51            .header("content-type", "application/json")
52            .header("accept", "application/json")
53    }
54
55    fn repository_request(
56        &self,
57        method: http::Method,
58        path_suffix: &str,
59        api_key: Option<&str>,
60    ) -> http::request::Builder {
61        make_request(self.repository_base.clone(), method, path_suffix, api_key)
62    }
63}
64impl Default for Config {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70fn make_request(
71    base: http::Uri,
72    method: http::Method,
73    path_suffix: &str,
74    api_key: Option<&str>,
75) -> http::request::Builder {
76    let mut parts = base.into_parts();
77    parts.path_and_query = Some(
78        match parts.path_and_query {
79            Some(path) => format!("{}{}", path, path_suffix).try_into(),
80            None => path_suffix.try_into(),
81        }
82        .expect("api_uri path"),
83    );
84    let uri = http::Uri::from_parts(parts).expect("api_uri building");
85    let mut builder = http::Request::builder()
86        .method(method)
87        .uri(uri)
88        .header("user-agent", USER_AGENT);
89    if let Some(key) = api_key {
90        builder = builder.header("authorization", key);
91    }
92    builder
93}
94
95/// Create a request that creates a Hex API key.
96///
97/// API Docs:
98///
99/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.ex#L137
100///
101/// https://github.com/hexpm/hex/blob/main/lib/hex/api/key.ex#L6
102pub fn create_api_key_request(
103    username: &str,
104    password: &str,
105    key_name: &str,
106    config: &Config,
107) -> http::Request<Vec<u8>> {
108    let body = json!({
109        "name": key_name,
110        "permissions": [{
111            "domain": "api",
112            "resource": "write",
113        }],
114    });
115    let creds = http_auth_basic::Credentials::new(username, password).as_http_header();
116    config
117        .api_request(Method::POST, "keys", None)
118        .header("authorization", creds)
119        .body(body.to_string().into_bytes())
120        .expect("create_api_key_request request")
121}
122
123/// Parses a request that creates a Hex API key.
124pub fn create_api_key_response(response: http::Response<Vec<u8>>) -> Result<String, ApiError> {
125    #[derive(Deserialize)]
126    struct Resp {
127        secret: String,
128    }
129    let (parts, body) = response.into_parts();
130    match parts.status {
131        StatusCode::CREATED => Ok(serde_json::from_slice::<Resp>(&body)?.secret),
132        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
133        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidCredentials),
134        status => Err(ApiError::unexpected_response(status, body)),
135    }
136}
137
138/// Create a request that deletes an Hex API key.
139///
140/// API Docs:
141///
142/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.user.ex#L291
143///
144/// https://github.com/hexpm/hex/blob/main/lib/hex/api/key.ex#L15
145pub fn remove_api_key_request(
146    name_of_key_to_delete: &str,
147    api_key: &str,
148    config: &Config,
149) -> http::Request<Vec<u8>> {
150    config
151        .api_request(
152            Method::DELETE,
153            &format!("keys/{}", name_of_key_to_delete),
154            Some(api_key),
155        )
156        .body(vec![])
157        .expect("remove_api_key_request request")
158}
159
160/// Parses a request that deleted a Hex API key.
161pub fn remove_api_key_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
162    let (parts, body) = response.into_parts();
163    match parts.status {
164        StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
165        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
166        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidCredentials),
167        status => Err(ApiError::unexpected_response(status, body)),
168    }
169}
170
171/// Retire an existing package release from Hex.
172///
173/// API Docs:
174///
175/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.retire.ex#L75
176///
177/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L28
178pub fn retire_release_request(
179    package: &str,
180    version: &str,
181    reason: RetirementReason,
182    message: Option<&str>,
183    api_key: &str,
184    config: &Config,
185) -> http::Request<Vec<u8>> {
186    let body = json!({
187        "reason": reason.to_str(),
188        "message": message,
189    });
190    config
191        .api_request(
192            Method::POST,
193            &format!("packages/{}/releases/{}/retire", package, version),
194            Some(api_key),
195        )
196        .body(body.to_string().into_bytes())
197        .expect("retire_release_request request")
198}
199
200/// Parses a request that retired a release.
201pub fn retire_release_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
202    let (parts, body) = response.into_parts();
203    match parts.status {
204        StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
205        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
206        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidCredentials),
207        status => Err(ApiError::unexpected_response(status, body)),
208    }
209}
210
211/// Un-retire an existing retired package release from Hex.
212///
213/// API Docs:
214///
215/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.retire.ex#L89
216///
217/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L35
218pub fn unretire_release_request(
219    package: &str,
220    version: &str,
221    api_key: &str,
222    config: &Config,
223) -> http::Request<Vec<u8>> {
224    config
225        .api_request(
226            Method::DELETE,
227            &format!("packages/{}/releases/{}/retire", package, version),
228            Some(api_key),
229        )
230        .body(vec![])
231        .expect("unretire_release_request request")
232}
233
234/// Parses a request that un-retired a package version.
235pub fn unretire_release_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
236    let (parts, body) = response.into_parts();
237    match parts.status {
238        StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
239        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
240        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidCredentials),
241        status => Err(ApiError::unexpected_response(status, body)),
242    }
243}
244
245/// Create a request that get the names and versions of all of the packages on
246/// the package registry.
247/// TODO: Where are the API docs for this?
248pub fn get_repository_versions_request(
249    api_key: Option<&str>,
250    config: &Config,
251) -> http::Request<Vec<u8>> {
252    config
253        .repository_request(Method::GET, "versions", api_key)
254        .header("accept", "application/json")
255        .body(vec![])
256        .expect("get_repository_versions_request request")
257}
258
259/// Parse a request that get the names and versions of all of the packages on
260/// the package registry.
261///
262pub fn get_repository_versions_response(
263    response: http::Response<Vec<u8>>,
264    public_key: &[u8],
265) -> Result<HashMap<String, Vec<Version>>, ApiError> {
266    let (parts, body) = response.into_parts();
267
268    match parts.status {
269        StatusCode::OK => (),
270        status => return Err(ApiError::unexpected_response(status, body)),
271    };
272
273    let mut decoder = GzDecoder::new(body.reader());
274    let mut body = Vec::new();
275    decoder.read_to_end(&mut body)?;
276
277    let signed = Signed::decode(body.as_slice())?;
278
279    let payload =
280        verify_payload(signed, public_key).map_err(|_| ApiError::IncorrectPayloadSignature)?;
281
282    let versions = Versions::decode(payload.as_slice())?
283        .packages
284        .into_iter()
285        .map(|n| {
286            let parse_version = |v: &str| {
287                let err = |_| ApiError::InvalidVersionFormat(v.to_string());
288                Version::parse(v).map_err(err)
289            };
290            let versions = n
291                .versions
292                .iter()
293                .map(|v| parse_version(v.as_str()))
294                .collect::<Result<Vec<Version>, ApiError>>()?;
295            Ok((n.name, versions))
296        })
297        .collect::<Result<HashMap<_, _>, ApiError>>()?;
298
299    Ok(versions)
300}
301
302/// Create a request to get the information for a package in the repository.
303///
304/// API Docs:
305///
306/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.package.ex#L348
307///
308/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L36
309pub fn get_package_request(
310    name: &str,
311    api_key: Option<&str>,
312    config: &Config,
313) -> http::Request<Vec<u8>> {
314    config
315        .repository_request(Method::GET, &format!("packages/{}", name), api_key)
316        .header("accept", "application/json")
317        .body(vec![])
318        .expect("get_package_request request")
319}
320
321/// Parse a response to get the information for a package in the repository.
322///
323pub fn get_package_response(
324    response: http::Response<Vec<u8>>,
325    public_key: &[u8],
326) -> Result<Package, ApiError> {
327    let (parts, body) = response.into_parts();
328
329    match parts.status {
330        StatusCode::OK => (),
331        StatusCode::FORBIDDEN => return Err(ApiError::NotFound),
332        StatusCode::NOT_FOUND => return Err(ApiError::NotFound),
333        status => {
334            return Err(ApiError::unexpected_response(status, body));
335        }
336    };
337
338    let mut decoder = GzDecoder::new(body.reader());
339    let mut body = Vec::new();
340    decoder.read_to_end(&mut body)?;
341
342    let signed = Signed::decode(body.as_slice())?;
343
344    let payload =
345        verify_payload(signed, public_key).map_err(|_| ApiError::IncorrectPayloadSignature)?;
346
347    let package = proto::package::Package::decode(payload.as_slice())?;
348    let releases = package
349        .releases
350        .clone()
351        .into_iter()
352        .map(proto_to_release)
353        .collect::<Result<Vec<_>, _>>()?;
354    let package = Package {
355        name: package.name,
356        repository: package.repository,
357        releases,
358    };
359
360    Ok(package)
361}
362
363/// Create a request to download a version of a package as a tarball
364/// TODO: Where are the API docs for this?
365pub fn get_package_tarball_request(
366    name: &str,
367    version: &str,
368    api_key: Option<&str>,
369    config: &Config,
370) -> http::Request<Vec<u8>> {
371    config
372        .repository_request(
373            Method::GET,
374            &format!("tarballs/{}-{}.tar", name, version),
375            api_key,
376        )
377        .header("accept", "application/x-tar")
378        .body(vec![])
379        .expect("get_package_tarball_request request")
380}
381
382/// Parse a response to download a version of a package as a tarball
383///
384pub fn get_package_tarball_response(
385    response: http::Response<Vec<u8>>,
386    checksum: &[u8],
387) -> Result<Vec<u8>, ApiError> {
388    let (parts, body) = response.into_parts();
389    match parts.status {
390        StatusCode::OK => (),
391        StatusCode::FORBIDDEN => return Err(ApiError::NotFound),
392        StatusCode::NOT_FOUND => return Err(ApiError::NotFound),
393        status => {
394            return Err(ApiError::unexpected_response(status, body));
395        }
396    };
397    let body = read_and_check_body(body.reader(), checksum)?;
398    Ok(body)
399}
400
401/// API Docs:
402///
403/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L384
404///
405/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release_docs.ex#L19
406pub fn remove_docs_request(
407    package_name: &str,
408    version: &str,
409    api_key: &str,
410    config: &Config,
411) -> Result<http::Request<Vec<u8>>, ApiError> {
412    validate_package_and_version(package_name, version)?;
413
414    Ok(config
415        .api_request(
416            Method::DELETE,
417            &format!("packages/{}/releases/{}/docs", package_name, version),
418            Some(api_key),
419        )
420        .body(vec![])
421        .expect("remove_docs_request request"))
422}
423
424pub fn remove_docs_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
425    let (parts, body) = response.into_parts();
426    match parts.status {
427        StatusCode::NO_CONTENT => Ok(()),
428        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
429        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
430        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
431        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
432        status => Err(ApiError::unexpected_response(status, body)),
433    }
434}
435
436/// API Docs:
437///
438/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L429
439///
440/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release_docs.ex#L11
441pub fn publish_docs_request(
442    package_name: &str,
443    version: &str,
444    gzipped_tarball: Vec<u8>,
445    api_key: &str,
446    config: &Config,
447) -> Result<http::Request<Vec<u8>>, ApiError> {
448    validate_package_and_version(package_name, version)?;
449
450    Ok(config
451        .api_request(
452            Method::POST,
453            &format!("packages/{}/releases/{}/docs", package_name, version),
454            Some(api_key),
455        )
456        .header("content-encoding", "x-gzip")
457        .header("content-type", "application/x-tar")
458        .body(gzipped_tarball)
459        .expect("publish_docs_request request"))
460}
461
462pub fn publish_docs_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
463    let (parts, body) = response.into_parts();
464    match parts.status {
465        StatusCode::CREATED => Ok(()),
466        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
467        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
468        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
469        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
470        status => Err(ApiError::unexpected_response(status, body)),
471    }
472}
473
474/// API Docs:
475///
476/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L512
477///
478/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L13
479pub fn publish_package_request(
480    release_tarball: Vec<u8>,
481    api_key: &str,
482    config: &Config,
483    replace: bool,
484) -> http::Request<Vec<u8>> {
485    // TODO: do all the package tarball construction
486    config
487        .api_request(
488            Method::POST,
489            format!("publish?replace={}", replace).as_str(),
490            Some(api_key),
491        )
492        .header("content-type", "application/x-tar")
493        .body(release_tarball)
494        .expect("publish_package_request request")
495}
496
497pub fn publish_package_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
498    // TODO: return data from body
499    let (parts, body) = response.into_parts();
500    match parts.status {
501        StatusCode::OK | StatusCode::CREATED => Ok(()),
502        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
503        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
504        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
505        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
506        StatusCode::UNPROCESSABLE_ENTITY => {
507            let body = &String::from_utf8_lossy(&body).to_string();
508            if body.contains("--replace") {
509                return Err(ApiError::NotReplacing);
510            }
511            Err(ApiError::LateModification)
512        }
513        status => Err(ApiError::unexpected_response(status, body)),
514    }
515}
516
517/// API Docs:
518///
519/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L371
520///
521/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L21
522pub fn revert_release_request(
523    package_name: &str,
524    version: &str,
525    api_key: &str,
526    config: &Config,
527) -> Result<http::Request<Vec<u8>>, ApiError> {
528    validate_package_and_version(package_name, version)?;
529
530    Ok(config
531        .api_request(
532            Method::DELETE,
533            &format!("packages/{}/releases/{}", package_name, version),
534            Some(api_key),
535        )
536        .body(vec![])
537        .expect("publish_package_request request"))
538}
539
540pub fn revert_release_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
541    let (parts, body) = response.into_parts();
542    match parts.status {
543        StatusCode::NO_CONTENT => Ok(()),
544        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
545        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
546        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
547        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
548        status => Err(ApiError::unexpected_response(status, body)),
549    }
550}
551
552/// See: https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L47
553#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
554pub enum OwnerLevel {
555    /// Has every package permission EXCEPT the ability to change who owns the package
556    Maintainer,
557    /// Has every package permission including the ability to change who owns the package
558    Full,
559}
560
561impl Display for OwnerLevel {
562    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563        match self {
564            OwnerLevel::Maintainer => write!(f, "maintainer"),
565            OwnerLevel::Full => write!(f, "full"),
566        }
567    }
568}
569
570/// API Docs:
571///
572/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L107
573///
574/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L19
575pub fn add_owner_request(
576    package_name: &str,
577    owner: &str,
578    level: OwnerLevel,
579    api_key: &str,
580    config: &Config,
581) -> http::Request<Vec<u8>> {
582    let body = json!({
583        "level": level.to_string(),
584        "transfer": false,
585    });
586
587    config
588        .api_request(
589            Method::PUT,
590            &format!("packages/{}/owners/{}", package_name, owner),
591            Some(api_key),
592        )
593        .body(body.to_string().into_bytes())
594        .expect("add_owner_request request")
595}
596
597pub fn add_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
598    let (parts, body) = response.into_parts();
599    match parts.status {
600        StatusCode::NO_CONTENT => Ok(()),
601        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
602        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
603        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
604        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
605        status => Err(ApiError::unexpected_response(status, body)),
606    }
607}
608
609/// API Docs:
610///
611/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L125
612///
613/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L19
614pub fn transfer_owner_request(
615    package_name: &str,
616    owner: &str,
617    api_key: &str,
618    config: &Config,
619) -> http::Request<Vec<u8>> {
620    let body = json!({
621        "level": OwnerLevel::Full.to_string(),
622        "transfer": true,
623    });
624
625    config
626        .api_request(
627            Method::PUT,
628            &format!("packages/{}/owners/{}", package_name, owner),
629            Some(api_key),
630        )
631        .body(body.to_string().into_bytes())
632        .expect("transfer_owner_request request")
633}
634
635pub fn transfer_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
636    let (parts, body) = response.into_parts();
637    match parts.status {
638        StatusCode::NO_CONTENT => Ok(()),
639        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
640        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
641        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
642        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
643        status => Err(ApiError::unexpected_response(status, body)),
644    }
645}
646
647/// API Docs:
648///
649/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L139
650///
651/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L28
652pub fn remove_owner_request(
653    package_name: &str,
654    owner: &str,
655    api_key: &str,
656    config: &Config,
657) -> http::Request<Vec<u8>> {
658    config
659        .api_request(
660            Method::DELETE,
661            &format!("packages/{}/owners/{}", package_name, owner),
662            Some(api_key),
663        )
664        .body(vec![])
665        .expect("remove_owner_request request")
666}
667
668pub fn remove_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
669    let (parts, body) = response.into_parts();
670    match parts.status {
671        StatusCode::NO_CONTENT => Ok(()),
672        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
673        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
674        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
675        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
676        status => Err(ApiError::unexpected_response(status, body)),
677    }
678}
679
680#[derive(Error, Debug)]
681pub enum ApiError {
682    #[error(transparent)]
683    Json(#[from] serde_json::Error),
684
685    #[error(transparent)]
686    Io(#[from] std::io::Error),
687
688    #[error("the rate limit for the Hex API has been exceeded for this IP")]
689    RateLimited,
690
691    #[error("invalid username and password combination")]
692    InvalidCredentials,
693
694    #[error("an unexpected response was sent by Hex: {0}: {1}")]
695    UnexpectedResponse(StatusCode, String),
696
697    #[error("the given package name {0} is not valid")]
698    InvalidPackageNameFormat(String),
699
700    #[error("the payload signature does not match the downloaded payload")]
701    IncorrectPayloadSignature,
702
703    #[error(transparent)]
704    InvalidProtobuf(#[from] prost::DecodeError),
705
706    #[error("unexpected version format {0}")]
707    InvalidVersionFormat(String),
708
709    #[error("resource was not found")]
710    NotFound,
711
712    #[error("the version requirement format {0} is not valid")]
713    InvalidVersionRequirementFormat(String),
714
715    #[error("the downloaded data did not have the expected checksum")]
716    IncorrectChecksum,
717
718    #[error("the given API key was not valid")]
719    InvalidApiKey,
720
721    #[error("this account is not authorized for this action")]
722    Forbidden,
723
724    #[error("must explicitly express your intention to replace the release")]
725    NotReplacing,
726
727    #[error("can only modify a release up to one hour after publication")]
728    LateModification,
729}
730
731impl ApiError {
732    fn unexpected_response(status: StatusCode, body: Vec<u8>) -> Self {
733        ApiError::UnexpectedResponse(status, String::from_utf8_lossy(&body).to_string())
734    }
735
736    /// Returns `true` if the api error is [`NotFound`].
737    ///
738    /// [`NotFound`]: ApiError::NotFound
739    pub fn is_not_found(&self) -> bool {
740        matches!(self, Self::NotFound)
741    }
742}
743
744/// Read a body and ensure it has the given sha256 digest.
745fn read_and_check_body(reader: impl std::io::Read, checksum: &[u8]) -> Result<Vec<u8>, ApiError> {
746    use std::io::Read;
747    let mut reader = BufReader::new(reader);
748    let mut context = Context::new(&SHA256);
749    let mut buffer = [0; 1024];
750    let mut body = Vec::new();
751
752    loop {
753        let count = reader.read(&mut buffer)?;
754        if count == 0 {
755            break;
756        }
757        let bytes = &buffer[..count];
758        context.update(bytes);
759        body.extend_from_slice(bytes);
760    }
761
762    let digest = context.finish();
763    if digest.as_ref() == checksum {
764        Ok(body)
765    } else {
766        Err(ApiError::IncorrectChecksum)
767    }
768}
769
770fn proto_to_retirement_status(
771    status: Option<proto::package::RetirementStatus>,
772) -> Option<RetirementStatus> {
773    status.map(|stat| RetirementStatus {
774        message: stat.message().into(),
775        reason: proto_to_retirement_reason(stat.reason()),
776    })
777}
778
779fn proto_to_retirement_reason(reason: proto::package::RetirementReason) -> RetirementReason {
780    use proto::package::RetirementReason::*;
781    match reason {
782        RetiredOther => RetirementReason::Other,
783        RetiredInvalid => RetirementReason::Invalid,
784        RetiredSecurity => RetirementReason::Security,
785        RetiredDeprecated => RetirementReason::Deprecated,
786        RetiredRenamed => RetirementReason::Renamed,
787    }
788}
789
790fn proto_to_dep(dep: proto::package::Dependency) -> Result<(String, Dependency), ApiError> {
791    let app = dep.app;
792    let repository = dep.repository;
793    let requirement = Range::new(dep.requirement.clone())
794        .map_err(|_| ApiError::InvalidVersionFormat(dep.requirement))?;
795    Ok((
796        dep.package,
797        Dependency {
798            requirement,
799            optional: dep.optional.is_some(),
800            app,
801            repository,
802        },
803    ))
804}
805
806fn proto_to_release(release: proto::package::Release) -> Result<Release<()>, ApiError> {
807    let dependencies = release
808        .dependencies
809        .clone()
810        .into_iter()
811        .map(proto_to_dep)
812        .collect::<Result<HashMap<_, _>, _>>()?;
813    let version = Version::try_from(release.version.as_str())
814        .expect("Failed to parse version format from Hex");
815    Ok(Release {
816        version,
817        outer_checksum: release.outer_checksum.unwrap_or_default(),
818        retirement_status: proto_to_retirement_status(release.retired),
819        requirements: dependencies,
820        meta: (),
821    })
822}
823
824#[derive(Debug, PartialEq, Eq, Clone)]
825pub struct Package {
826    pub name: String,
827    pub repository: String,
828    pub releases: Vec<Release<()>>,
829}
830
831#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
832pub struct Release<Meta> {
833    /// Release version
834    pub version: Version,
835    /// All dependencies of the release
836    pub requirements: HashMap<String, Dependency>,
837    /// If set the release is retired, a retired release should only be
838    /// resolved if it has already been locked in a project
839    pub retirement_status: Option<RetirementStatus>,
840    /// sha256 checksum of outer package tarball
841    /// required when encoding but optional when decoding
842    #[serde(alias = "checksum", deserialize_with = "deserialize_checksum")]
843    pub outer_checksum: Vec<u8>,
844    /// This is not present in all API endpoints so may be absent sometimes.
845    pub meta: Meta,
846}
847
848fn deserialize_checksum<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
849where
850    D: serde::Deserializer<'de>,
851{
852    let s: &str = serde::de::Deserialize::deserialize(deserializer)?;
853    base16::decode(s).map_err(serde::de::Error::custom)
854}
855
856impl<Meta> Release<Meta> {
857    pub fn is_retired(&self) -> bool {
858        self.retirement_status.is_some()
859    }
860}
861
862#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
863pub struct ReleaseMeta {
864    pub app: String,
865    pub build_tools: Vec<String>,
866}
867
868#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
869pub struct RetirementStatus {
870    pub reason: RetirementReason,
871    pub message: String,
872}
873
874#[derive(Debug, PartialEq, Eq, Clone)]
875pub enum RetirementReason {
876    Other,
877    Invalid,
878    Security,
879    Deprecated,
880    Renamed,
881}
882
883impl<'de> serde::Deserialize<'de> for RetirementReason {
884    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
885    where
886        D: serde::Deserializer<'de>,
887    {
888        let s: &str = serde::de::Deserialize::deserialize(deserializer)?;
889        match s {
890            "other" => Ok(RetirementReason::Other),
891            "invalid" => Ok(RetirementReason::Invalid),
892            "security" => Ok(RetirementReason::Security),
893            "deprecated" => Ok(RetirementReason::Deprecated),
894            "renamed" => Ok(RetirementReason::Renamed),
895            _ => Err(serde::de::Error::custom("unknown retirement reason type")),
896        }
897    }
898}
899
900impl RetirementReason {
901    pub fn to_str(&self) -> &'static str {
902        match self {
903            RetirementReason::Other => "other",
904            RetirementReason::Invalid => "invalid",
905            RetirementReason::Security => "security",
906            RetirementReason::Deprecated => "deprecated",
907            RetirementReason::Renamed => "renamed",
908        }
909    }
910}
911
912#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
913pub struct Dependency {
914    /// Version requirement of dependency
915    pub requirement: Range,
916    /// If true the package is optional and does not need to be resolved
917    /// unless another package has specified it as a non-optional dependency.
918    pub optional: bool,
919    /// If set is the OTP application name of the dependency, if not set the
920    /// application name is the same as the package name
921    pub app: Option<String>,
922    /// If set, the repository where the dependency is located
923    pub repository: Option<String>,
924}
925
926static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), " (", env!("CARGO_PKG_VERSION"), ")");
927
928fn validate_package_and_version(package: &str, version: &str) -> Result<(), ApiError> {
929    lazy_static! {
930        static ref PACKAGE_PATTERN: Regex = Regex::new(r"^[a-z]\w*$").unwrap();
931        static ref VERSION_PATTERN: Regex = Regex::new(r"^[a-zA-Z-0-9\._-]+$").unwrap();
932    }
933    if !PACKAGE_PATTERN.is_match(package) {
934        return Err(ApiError::InvalidPackageNameFormat(package.to_string()));
935    }
936    if !VERSION_PATTERN.is_match(version) {
937        return Err(ApiError::InvalidVersionFormat(version.to_string()));
938    }
939    Ok(())
940}
941
942// To quote the docs:
943//
944// > All resources will be signed by the repository's private key.
945// > A signed resource is wrapped in a Signed message. The data under
946// > the payload field is signed by the signature field.
947// >
948// > The signature is an (unencoded) RSA signature of the (unencoded)
949// > SHA-512 digest of the payload.
950//
951// https://github.com/hexpm/specifications/blob/master/registry-v2.md#signing
952//
953fn verify_payload(mut signed: Signed, pem_public_key: &[u8]) -> Result<Vec<u8>, ApiError> {
954    let (_, pem) = x509_parser::pem::parse_x509_pem(pem_public_key)
955        .map_err(|_| ApiError::IncorrectPayloadSignature)?;
956    let (_, spki) = x509_parser::prelude::SubjectPublicKeyInfo::from_der(&pem.contents)
957        .map_err(|_| ApiError::IncorrectPayloadSignature)?;
958    let payload = std::mem::take(&mut signed.payload);
959    let verification = ring::signature::UnparsedPublicKey::new(
960        &ring::signature::RSA_PKCS1_2048_8192_SHA512,
961        &spki.subject_public_key,
962    )
963    .verify(payload.as_slice(), signed.signature());
964
965    if verification.is_ok() {
966        Ok(payload)
967    } else {
968        Err(ApiError::IncorrectPayloadSignature)
969    }
970}
971
972/// Create a request to get the information for a package release.
973///
974pub fn get_package_release_request(
975    name: &str,
976    version: &str,
977    api_key: Option<&str>,
978    config: &Config,
979) -> http::Request<Vec<u8>> {
980    config
981        .api_request(
982            Method::GET,
983            &format!("packages/{}/releases/{}", name, version),
984            api_key,
985        )
986        .header("accept", "application/json")
987        .body(vec![])
988        .expect("get_package_release request")
989}
990
991/// Parse a response to get the information for a package release.
992///
993pub fn get_package_release_response(
994    response: http::Response<Vec<u8>>,
995) -> Result<Release<ReleaseMeta>, ApiError> {
996    let (parts, body) = response.into_parts();
997
998    match parts.status {
999        StatusCode::OK => Ok(serde_json::from_slice(&body)?),
1000        StatusCode::NOT_FOUND => Err(ApiError::NotFound),
1001        StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
1002        StatusCode::UNAUTHORIZED => Err(ApiError::InvalidApiKey),
1003        StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
1004        status => Err(ApiError::unexpected_response(status, body)),
1005    }
1006}