Fork of daniellemaywood.uk/gleam — Wasm codegen work
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2026 The Gleam contributors
3
4mod proto;
5
6#[cfg(test)]
7mod tests;
8
9pub mod version;
10
11use crate::proto::{signed::Signed, versions::Versions};
12use bytes::buf::Buf;
13use clap::ValueEnum;
14use ecow::EcoString;
15use flate2::read::GzDecoder;
16use http::{Method, StatusCode};
17use prost::Message;
18use regex::Regex;
19use ring::digest::{Context, SHA256};
20use serde::{Deserialize, Serialize};
21use serde_json::json;
22use std::sync::OnceLock;
23use std::time::{Duration, Instant};
24use std::{
25 collections::HashMap,
26 convert::{TryFrom, TryInto},
27 io::{BufReader, Read},
28};
29use thiserror::Error;
30use version::{Range, Version};
31use x509_parser::prelude::FromDer;
32
33#[derive(Debug, Clone)]
34pub struct Config {
35 /// Defaults to https://hex.pm/api/
36 pub api_base: http::Uri,
37 /// Defaults to https://repo.hex.pm/
38 pub repository_base: http::Uri,
39}
40
41impl Config {
42 pub fn new() -> Self {
43 Self {
44 api_base: http::Uri::from_static("https://hex.pm/api/"),
45 repository_base: http::Uri::from_static("https://repo.hex.pm/"),
46 }
47 }
48
49 fn api_request(&self, method: http::Method, path_suffix: &str) -> RequestBuilder {
50 RequestBuilder {
51 builder: make_request(self.api_base.clone(), method, path_suffix)
52 .header("content-type", "application/json")
53 .header("accept", "application/json"),
54 }
55 }
56
57 fn repository_request(
58 &self,
59 method: http::Method,
60 path_suffix: &str,
61 credentials: Option<&Credentials>,
62 ) -> http::request::Builder {
63 RequestBuilder {
64 builder: make_request(self.repository_base.clone(), method, path_suffix),
65 }
66 .read_credentials(credentials)
67 }
68}
69
70impl Default for Config {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76struct RequestBuilder {
77 builder: http::request::Builder,
78}
79
80impl RequestBuilder {
81 pub fn read_credentials(self, credentials: Option<&Credentials>) -> http::request::Builder {
82 let mut builder = self.builder;
83 match credentials {
84 Some(Credentials::OAuthAccessToken(token)) => {
85 builder = builder.header("authorization", format!("Bearer {token}"));
86 }
87 Some(Credentials::ApiKey(key)) => {
88 builder = builder.header("authorization", key.to_string());
89 }
90 None => (),
91 }
92 builder
93 }
94
95 pub fn write_credentials(self, credentials: &WriteActionCredentials) -> http::request::Builder {
96 let mut builder = self.builder;
97 match credentials {
98 WriteActionCredentials::OAuthAccessToken {
99 access_token,
100 one_time_password,
101 } => {
102 builder = builder.header("authorization", format!("Bearer {access_token}"));
103 builder = builder.header("x-hex-otp", one_time_password.to_string());
104 }
105 WriteActionCredentials::ApiKey(key) => {
106 builder = builder.header("authorization", key.to_string());
107 }
108 }
109 builder
110 }
111}
112
113#[derive(Debug, Clone)]
114pub enum Credentials {
115 // Short lived credential from OAuth
116 OAuthAccessToken(EcoString),
117 // Long lived API key
118 ApiKey(EcoString),
119}
120
121#[derive(Debug, Clone)]
122pub enum WriteActionCredentials {
123 // Short lived credential from OAuth.
124 // A one-time-password is required for write actions when using OAuth
125 OAuthAccessToken {
126 access_token: EcoString,
127 one_time_password: EcoString,
128 },
129 // Long lived API key
130 ApiKey(EcoString),
131}
132
133fn make_request(
134 base: http::Uri,
135 method: http::Method,
136 path_suffix: &str,
137) -> http::request::Builder {
138 let mut parts = base.into_parts();
139 parts.path_and_query = Some(
140 match parts.path_and_query {
141 Some(path_and_query) => {
142 let mut path = path_and_query.path().to_owned();
143 if !path.ends_with('/') {
144 path.push('/');
145 }
146 path += path_suffix;
147
148 // Drop query parameters
149 path.try_into()
150 }
151 None => path_suffix.try_into(),
152 }
153 .expect("api_uri path"),
154 );
155 let uri = http::Uri::from_parts(parts).expect("api_uri building");
156 http::Request::builder()
157 .method(method)
158 .uri(uri)
159 .header("user-agent", USER_AGENT)
160}
161
162/// Create a request that deletes an Hex API key.
163///
164/// API Docs:
165///
166/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.user.ex#L291
167///
168/// https://github.com/hexpm/hex/blob/main/lib/hex/api/key.ex#L15
169pub fn api_remove_api_key_request(
170 name_of_key_to_delete: &str,
171 credentials: &WriteActionCredentials,
172 config: &Config,
173) -> http::Request<Vec<u8>> {
174 let path = format!("keys/{name_of_key_to_delete}");
175 config
176 .api_request(Method::DELETE, &path)
177 .write_credentials(credentials)
178 .body(vec![])
179 .expect("remove_api_key_request request")
180}
181
182/// Parses a request that deleted a Hex API key.
183pub fn api_remove_api_key_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
184 let (parts, body) = response.into_parts();
185
186 match parts.status {
187 // Key was removed
188 StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
189 // Key has already been removed
190 StatusCode::NOT_FOUND => Ok(()),
191 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
192 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
193 status => Err(ApiError::unexpected_response(status, body)),
194 }
195}
196
197fn unauthorised_response(headers: &http::HeaderMap) -> ApiError {
198 let authenticate_header = headers
199 .get("www-authenticate")
200 .and_then(|header| header.to_str().ok())
201 .unwrap_or_default();
202
203 if authenticate_header.starts_with("Bearer realm=\"hex\", error=\"invalid_totp\"") {
204 return ApiError::IncorrectOneTimePassword;
205 }
206
207 ApiError::InvalidCredentials
208}
209
210/// Retire an existing package release from Hex.
211///
212/// API Docs:
213///
214/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.retire.ex#L75
215///
216/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L28
217pub fn api_retire_release_request(
218 package: &str,
219 version: &str,
220 reason: RetirementReason,
221 message: Option<&str>,
222 credentials: &WriteActionCredentials,
223 config: &Config,
224) -> http::Request<Vec<u8>> {
225 let body = json!({
226 "reason": reason.to_str(),
227 "message": message,
228 });
229 let path = format!("packages/{package}/releases/{version}/retire");
230 config
231 .api_request(Method::POST, &path)
232 .write_credentials(credentials)
233 .body(body.to_string().into_bytes())
234 .expect("retire_release_request request")
235}
236
237/// Parses a request that retired a release.
238pub fn api_retire_release_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
239 let (parts, body) = response.into_parts();
240 match parts.status {
241 StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
242 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
243 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
244 status => Err(ApiError::unexpected_response(status, body)),
245 }
246}
247
248/// Un-retire an existing retired package release from Hex.
249///
250/// API Docs:
251///
252/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.retire.ex#L89
253///
254/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L35
255pub fn api_unretire_release_request(
256 package: &str,
257 version: &str,
258 credentials: &WriteActionCredentials,
259 config: &Config,
260) -> http::Request<Vec<u8>> {
261 let path = format!("packages/{package}/releases/{version}/retire");
262 config
263 .api_request(Method::DELETE, &path)
264 .write_credentials(credentials)
265 .body(vec![])
266 .expect("unretire_release_request request")
267}
268
269/// Parses a request that un-retired a package version.
270pub fn api_unretire_release_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
271 let (parts, body) = response.into_parts();
272 match parts.status {
273 StatusCode::NO_CONTENT | StatusCode::OK => Ok(()),
274 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
275 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
276 status => Err(ApiError::unexpected_response(status, body)),
277 }
278}
279
280/// Create a request that get the names and versions of all of the packages on
281/// the package registry.
282///
283/// https://github.com/hexpm/specifications/blob/main/registry-v2.md
284///
285/// TODO: Where are the API docs for this?
286pub fn repository_v2_get_versions_request(
287 credentials: Option<&Credentials>,
288 config: &Config,
289) -> http::Request<Vec<u8>> {
290 config
291 .repository_request(Method::GET, "versions", credentials)
292 .header("accept", "application/json")
293 .body(vec![])
294 .expect("get_repository_versions_request request")
295}
296
297/// Parse a request that gets the names and versions of all of the packages on
298/// the package registry.
299pub fn repository_v2_get_versions_response(
300 response: http::Response<Vec<u8>>,
301 public_key: &[u8],
302) -> Result<HashMap<String, Vec<Version>>, ApiError> {
303 let (parts, body) = response.into_parts();
304
305 match parts.status {
306 StatusCode::OK => (),
307 status => return Err(ApiError::unexpected_response(status, body)),
308 };
309
310 let mut decoder = GzDecoder::new(body.reader());
311 let mut body = Vec::new();
312 decoder.read_to_end(&mut body)?;
313
314 repository_v2_get_versions_body(&body, public_key)
315}
316
317/// Parse a signed binary message containing all of the packages on the package registry.
318pub fn repository_v2_get_versions_body(
319 protobuf_bytes: &Vec<u8>,
320 public_key: &[u8],
321) -> Result<HashMap<String, Vec<Version>>, ApiError> {
322 let signed = Signed::decode(protobuf_bytes.as_slice())?;
323
324 let payload =
325 verify_payload(signed, public_key).map_err(|_| ApiError::IncorrectPayloadSignature)?;
326
327 let versions = Versions::decode(payload.as_slice())?
328 .packages
329 .into_iter()
330 .map(|n| {
331 let parse_version = |v: &str| {
332 let err = |_| ApiError::InvalidVersionFormat(v.to_string());
333 Version::parse(v).map_err(err)
334 };
335 let versions = n
336 .versions
337 .iter()
338 .map(|v| parse_version(v.as_str()))
339 .collect::<Result<Vec<Version>, ApiError>>()?;
340 Ok((n.name, versions))
341 })
342 .collect::<Result<HashMap<_, _>, ApiError>>()?;
343
344 Ok(versions)
345}
346
347/// Create a request to get the information for a package in the repository.
348///
349/// https://github.com/hexpm/specifications/blob/main/registry-v2.md
350///
351pub fn repository_v2_get_package_request(
352 name: &str,
353 credentials: Option<&Credentials>,
354 config: &Config,
355) -> http::Request<Vec<u8>> {
356 config
357 .repository_request(Method::GET, &format!("packages/{name}"), credentials)
358 .header("accept", "application/json")
359 .body(vec![])
360 .expect("get_package_request request")
361}
362
363/// Parse a response to get the information for a package in the repository.
364///
365pub fn repository_v2_get_package_response(
366 response: http::Response<Vec<u8>>,
367 public_key: &[u8],
368) -> Result<Package, ApiError> {
369 let (parts, body) = response.into_parts();
370
371 match parts.status {
372 StatusCode::OK => (),
373 StatusCode::FORBIDDEN => return Err(ApiError::NotFound),
374 StatusCode::NOT_FOUND => return Err(ApiError::NotFound),
375 status => {
376 return Err(ApiError::unexpected_response(status, body));
377 }
378 };
379
380 let mut decoder = GzDecoder::new(body.reader());
381 let mut body = Vec::new();
382 decoder.read_to_end(&mut body)?;
383
384 repository_v2_package_parse_body(&body, public_key)
385}
386
387/// Parse a signed binary message containing the information for a package in the repository.
388pub fn repository_v2_package_parse_body(
389 protobuf_bytes: &Vec<u8>,
390 public_key: &[u8],
391) -> Result<Package, ApiError> {
392 let signed = Signed::decode(protobuf_bytes.as_slice())?;
393
394 let payload =
395 verify_payload(signed, public_key).map_err(|_| ApiError::IncorrectPayloadSignature)?;
396
397 let package = proto::package::Package::decode(payload.as_slice())?;
398 let releases = package
399 .releases
400 .clone()
401 .into_iter()
402 .map(proto_to_release)
403 .collect::<Result<Vec<_>, _>>()?;
404 let package = Package {
405 name: package.name,
406 repository: package.repository,
407 releases,
408 };
409
410 Ok(package)
411}
412
413/// Create a request to download a version of a package as a tarball
414/// TODO: Where are the API docs for this?
415pub fn repository_get_package_tarball_request(
416 name: &str,
417 version: &str,
418 credentials: Option<&Credentials>,
419 config: &Config,
420) -> http::Request<Vec<u8>> {
421 config
422 .repository_request(
423 Method::GET,
424 &format!("tarballs/{name}-{version}.tar"),
425 credentials,
426 )
427 .header("accept", "application/x-tar")
428 .body(vec![])
429 .expect("get_package_tarball_request request")
430}
431
432/// Parse a response to download a version of a package as a tarball
433///
434pub fn repository_get_package_tarball_response(
435 response: http::Response<Vec<u8>>,
436 checksum: &[u8],
437) -> Result<Vec<u8>, ApiError> {
438 let (parts, body) = response.into_parts();
439 match parts.status {
440 StatusCode::OK => (),
441 StatusCode::FORBIDDEN => return Err(ApiError::NotFound),
442 StatusCode::NOT_FOUND => return Err(ApiError::NotFound),
443 status => {
444 return Err(ApiError::unexpected_response(status, body));
445 }
446 };
447 let body = read_and_check_body(body.reader(), checksum)?;
448 Ok(body)
449}
450
451/// API Docs:
452///
453/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L384
454///
455/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release_docs.ex#L19
456pub fn api_remove_docs_request(
457 package_name: &str,
458 version: &str,
459 credentials: &WriteActionCredentials,
460 config: &Config,
461) -> Result<http::Request<Vec<u8>>, ApiError> {
462 validate_package_and_version(package_name, version)?;
463 let path = format!("packages/{package_name}/releases/{version}/docs");
464 Ok(config
465 .api_request(Method::DELETE, &path)
466 .write_credentials(credentials)
467 .body(vec![])
468 .expect("remove_docs_request request"))
469}
470
471pub fn api_remove_docs_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
472 let (parts, body) = response.into_parts();
473 match parts.status {
474 StatusCode::NO_CONTENT => Ok(()),
475 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
476 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
477 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
478 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
479 status => Err(ApiError::unexpected_response(status, body)),
480 }
481}
482
483/// API Docs:
484///
485/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L429
486///
487/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release_docs.ex#L11
488pub fn api_publish_docs_request(
489 package_name: &str,
490 version: &str,
491 gzipped_tarball: Vec<u8>,
492 credentials: &WriteActionCredentials,
493 config: &Config,
494) -> Result<http::Request<Vec<u8>>, ApiError> {
495 validate_package_and_version(package_name, version)?;
496 let path = format!("packages/{package_name}/releases/{version}/docs");
497 let mut builder = config
498 .api_request(Method::POST, &path)
499 .write_credentials(credentials);
500 let headers = builder.headers_mut().expect("headers");
501 headers.insert("content-encoding", "x-gzip".parse().unwrap());
502 headers.insert("content-type", "application/x-tar".parse().unwrap());
503 Ok(builder
504 .body(gzipped_tarball)
505 .expect("publish_docs_request request"))
506}
507
508pub fn api_publish_docs_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
509 let (parts, body) = response.into_parts();
510 match parts.status {
511 StatusCode::CREATED => Ok(()),
512 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
513 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
514 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
515 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
516 status => Err(ApiError::unexpected_response(status, body)),
517 }
518}
519
520/// API Docs:
521///
522/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L512
523///
524/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L13
525pub fn api_publish_package_request(
526 release_tarball: Vec<u8>,
527 credentials: &WriteActionCredentials,
528 config: &Config,
529 replace: bool,
530) -> http::Request<Vec<u8>> {
531 let path = format!("publish?replace={replace}");
532 let mut builder = config
533 .api_request(Method::POST, &path)
534 .write_credentials(credentials);
535 builder
536 .headers_mut()
537 .expect("headers")
538 .insert("content-type", "application/x-tar".parse().unwrap());
539 builder
540 .body(release_tarball)
541 .expect("publish_package_request request")
542}
543
544pub fn api_publish_package_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
545 let (parts, body) = response.into_parts();
546 match parts.status {
547 StatusCode::OK | StatusCode::CREATED => Ok(()),
548 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
549 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
550 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
551 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
552 StatusCode::UNPROCESSABLE_ENTITY => {
553 let body = String::from_utf8_lossy(&body);
554 if body.contains("--replace") {
555 Err(ApiError::NotReplacing)
556 } else if body.contains("can only modify a release up to one hour after publication") {
557 Err(ApiError::LateModification)
558 } else {
559 Err(ApiError::UnexpectedResponse(
560 StatusCode::UNPROCESSABLE_ENTITY,
561 body.to_string(),
562 ))
563 }
564 }
565 status => Err(ApiError::unexpected_response(status, body)),
566 }
567}
568
569/// API Docs:
570///
571/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L371
572///
573/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L21
574pub fn api_revert_release_request(
575 package_name: &str,
576 version: &str,
577 credentials: &WriteActionCredentials,
578 config: &Config,
579) -> Result<http::Request<Vec<u8>>, ApiError> {
580 validate_package_and_version(package_name, version)?;
581 let path = format!("packages/{package_name}/releases/{version}");
582 Ok(config
583 .api_request(Method::DELETE, &path)
584 .write_credentials(credentials)
585 .body(vec![])
586 .expect("publish_package_request request"))
587}
588
589pub fn api_revert_release_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
590 let (parts, body) = response.into_parts();
591 match parts.status {
592 StatusCode::NO_CONTENT => Ok(()),
593 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
594 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
595 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
596 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
597 StatusCode::UNPROCESSABLE_ENTITY if is_late_deletion(&body) => Err(ApiError::LateDeletion),
598 status => Err(ApiError::unexpected_response(status, body)),
599 }
600}
601
602fn is_late_deletion(body: &[u8]) -> bool {
603 String::from_utf8_lossy(body)
604 .contains("can only delete a release up to one hour after publication")
605}
606
607/// See: https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L47
608#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
609pub enum OwnerLevel {
610 /// Has every package permission EXCEPT the ability to change who owns the package
611 Maintainer,
612 /// Has every package permission including the ability to change who owns the package
613 Full,
614}
615
616/// API Docs:
617///
618/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L107
619///
620/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L19
621pub fn api_add_owner_request(
622 package_name: &str,
623 owner: &str,
624 level: OwnerLevel,
625 credentials: &WriteActionCredentials,
626 config: &Config,
627) -> http::Request<Vec<u8>> {
628 let body = json!({
629 "level": owner_level_to_json_string(level),
630 "transfer": false,
631 });
632 let path = format!("packages/{package_name}/owners/{owner}");
633 config
634 .api_request(Method::PUT, &path)
635 .write_credentials(credentials)
636 .body(body.to_string().into_bytes())
637 .expect("add_owner_request request")
638}
639
640/// Turns an `OwnerLevel` into a string that can be used with the json payload
641/// of requests to the Hex API expecting an owner level.
642fn owner_level_to_json_string(level: OwnerLevel) -> &'static str {
643 match level {
644 OwnerLevel::Maintainer => "maintainer",
645 OwnerLevel::Full => "full",
646 }
647}
648
649pub fn api_add_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
650 let (parts, body) = response.into_parts();
651 match parts.status {
652 StatusCode::NO_CONTENT => Ok(()),
653 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
654 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
655 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
656 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
657 status => Err(ApiError::unexpected_response(status, body)),
658 }
659}
660
661/// API Docs:
662///
663/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L125
664///
665/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L19
666pub fn api_transfer_owner_request(
667 package_name: &str,
668 owner: &str,
669 credentials: &WriteActionCredentials,
670 config: &Config,
671) -> http::Request<Vec<u8>> {
672 let body = json!({
673 "level": owner_level_to_json_string(OwnerLevel::Full),
674 "transfer": true,
675 });
676 let path = format!("packages/{package_name}/owners/{owner}");
677 config
678 .api_request(Method::PUT, &path)
679 .write_credentials(credentials)
680 .body(body.to_string().into_bytes())
681 .expect("transfer_owner_request request")
682}
683
684pub fn api_transfer_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
685 let (parts, body) = response.into_parts();
686 match parts.status {
687 StatusCode::NO_CONTENT => Ok(()),
688 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
689 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
690 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
691 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
692 status => Err(ApiError::unexpected_response(status, body)),
693 }
694}
695
696/// API Docs:
697///
698/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L139
699///
700/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L28
701pub fn api_remove_owner_request(
702 package_name: &str,
703 owner: &str,
704 credentials: &WriteActionCredentials,
705 config: &Config,
706) -> http::Request<Vec<u8>> {
707 let path = format!("packages/{package_name}/owners/{owner}");
708 config
709 .api_request(Method::DELETE, &path)
710 .write_credentials(credentials)
711 .body(vec![])
712 .expect("remove_owner_request request")
713}
714
715pub fn api_remove_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
716 let (parts, body) = response.into_parts();
717 match parts.status {
718 StatusCode::NO_CONTENT => Ok(()),
719 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
720 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
721 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
722 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
723 status => Err(ApiError::unexpected_response(status, body)),
724 }
725}
726
727#[derive(Error, Debug)]
728pub enum ApiError {
729 #[error(transparent)]
730 Json(#[from] serde_json::Error),
731
732 #[error(transparent)]
733 Io(#[from] std::io::Error),
734
735 #[error("The rate limit for the Hex API has been exceeded for this IP")]
736 RateLimited,
737
738 #[error("Invalid authentication credentials")]
739 InvalidCredentials,
740
741 #[error("An unexpected response was sent by Hex: {0}: {1}")]
742 UnexpectedResponse(StatusCode, String),
743
744 #[error("The given package name {0} is not valid")]
745 InvalidPackageNameFormat(String),
746
747 #[error("The payload signature does not match the downloaded payload")]
748 IncorrectPayloadSignature,
749
750 #[error(transparent)]
751 InvalidProtobuf(#[from] prost::DecodeError),
752
753 #[error("Unexpected version format {0}")]
754 InvalidVersionFormat(String),
755
756 #[error("Resource was not found")]
757 NotFound,
758
759 #[error("The version requirement format {0} is not valid")]
760 InvalidVersionRequirementFormat(String),
761
762 #[error("The downloaded data did not have the expected checksum")]
763 IncorrectChecksum,
764
765 #[error("This account is not authorized for this action")]
766 Forbidden,
767
768 #[error("Must explicitly express your intention to replace the release")]
769 NotReplacing,
770
771 #[error("Can only modify a release up to one hour after publication")]
772 LateModification,
773
774 #[error("Can only delete a release up to one hour after publication")]
775 LateDeletion,
776
777 #[error("The oauth request wasn't approved in time")]
778 OAuthTimeout,
779
780 #[error("The oauth request was rejected")]
781 OAuthAccessDenied,
782
783 #[error("The oauth token expired before the request was approved")]
784 ExpiredToken,
785
786 #[error("The oauth refresh token was expired, revoked, or already used")]
787 OAuthRefreshTokenRejected,
788
789 #[error("The supplied one-time-password was not correct")]
790 IncorrectOneTimePassword,
791}
792
793impl ApiError {
794 fn unexpected_response(status: StatusCode, body: Vec<u8>) -> Self {
795 ApiError::UnexpectedResponse(status, String::from_utf8_lossy(&body).to_string())
796 }
797
798 /// Returns `true` if the api error is [`NotFound`].
799 ///
800 /// [`NotFound`]: ApiError::NotFound
801 pub fn is_not_found(&self) -> bool {
802 matches!(self, Self::NotFound)
803 }
804
805 pub fn is_invalid_protobuf(&self) -> bool {
806 matches!(self, Self::InvalidProtobuf(_))
807 }
808}
809
810/// Read a body and ensure it has the given sha256 digest.
811fn read_and_check_body(reader: impl std::io::Read, checksum: &[u8]) -> Result<Vec<u8>, ApiError> {
812 use std::io::Read;
813 let mut reader = BufReader::new(reader);
814 let mut context = Context::new(&SHA256);
815 let mut buffer = [0; 1024];
816 let mut body = Vec::new();
817
818 loop {
819 let count = reader.read(&mut buffer)?;
820 if count == 0 {
821 break;
822 }
823 let bytes = &buffer[..count];
824 context.update(bytes);
825 body.extend_from_slice(bytes);
826 }
827
828 let digest = context.finish();
829 if digest.as_ref() == checksum {
830 Ok(body)
831 } else {
832 Err(ApiError::IncorrectChecksum)
833 }
834}
835
836fn proto_to_retirement_status(
837 status: Option<proto::package::RetirementStatus>,
838) -> Option<RetirementStatus> {
839 status.map(|stat| RetirementStatus {
840 message: stat.message().into(),
841 reason: proto_to_retirement_reason(stat.reason()),
842 })
843}
844
845fn proto_to_retirement_reason(reason: proto::package::RetirementReason) -> RetirementReason {
846 use proto::package::RetirementReason::*;
847 match reason {
848 RetiredOther => RetirementReason::Other,
849 RetiredInvalid => RetirementReason::Invalid,
850 RetiredSecurity => RetirementReason::Security,
851 RetiredDeprecated => RetirementReason::Deprecated,
852 RetiredRenamed => RetirementReason::Renamed,
853 }
854}
855
856fn proto_to_dep(dep: proto::package::Dependency) -> Result<(String, Dependency), ApiError> {
857 let app = dep.app;
858 let repository = dep.repository;
859 let requirement = Range::new(dep.requirement.clone())
860 .map_err(|_| ApiError::InvalidVersionFormat(dep.requirement))?;
861 Ok((
862 dep.package,
863 Dependency {
864 requirement,
865 optional: dep.optional.is_some(),
866 app,
867 repository,
868 },
869 ))
870}
871
872fn proto_to_release(release: proto::package::Release) -> Result<Release<()>, ApiError> {
873 let dependencies = release
874 .dependencies
875 .clone()
876 .into_iter()
877 .map(proto_to_dep)
878 .collect::<Result<HashMap<_, _>, _>>()?;
879 let version = Version::try_from(release.version.as_str())
880 .expect("Failed to parse version format from Hex");
881 Ok(Release {
882 version,
883 outer_checksum: release.outer_checksum.unwrap_or_default(),
884 retirement_status: proto_to_retirement_status(release.retired),
885 requirements: dependencies,
886 meta: (),
887 })
888}
889
890#[derive(Debug, PartialEq, Eq, Clone)]
891pub struct Package {
892 pub name: String,
893 pub repository: String,
894 pub releases: Vec<Release<()>>,
895}
896
897#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
898pub struct Release<Meta> {
899 /// Release version
900 pub version: Version,
901 /// All dependencies of the release
902 pub requirements: HashMap<String, Dependency>,
903 /// If set the release is retired, a retired release should only be
904 /// resolved if it has already been locked in a project
905 pub retirement_status: Option<RetirementStatus>,
906 /// sha256 checksum of outer package tarball
907 /// required when encoding but optional when decoding
908 #[serde(alias = "checksum", deserialize_with = "deserialize_checksum")]
909 pub outer_checksum: Vec<u8>,
910 /// This is not present in all API endpoints so may be absent sometimes.
911 pub meta: Meta,
912}
913
914fn deserialize_checksum<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
915where
916 D: serde::Deserializer<'de>,
917{
918 let s: &str = serde::de::Deserialize::deserialize(deserializer)?;
919 base16::decode(s).map_err(serde::de::Error::custom)
920}
921
922impl<Meta> Release<Meta> {
923 pub fn is_retired(&self) -> bool {
924 self.retirement_status.is_some()
925 }
926}
927
928#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
929pub struct ReleaseMeta {
930 pub app: String,
931 pub build_tools: Vec<String>,
932}
933
934#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
935pub struct RetirementStatus {
936 pub reason: RetirementReason,
937 pub message: String,
938}
939
940#[derive(Debug, PartialEq, Eq, Clone)]
941pub enum RetirementReason {
942 Other,
943 Invalid,
944 Security,
945 Deprecated,
946 Renamed,
947}
948
949impl<'de> serde::Deserialize<'de> for RetirementReason {
950 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
951 where
952 D: serde::Deserializer<'de>,
953 {
954 let s: &str = serde::de::Deserialize::deserialize(deserializer)?;
955 match s {
956 "other" => Ok(RetirementReason::Other),
957 "invalid" => Ok(RetirementReason::Invalid),
958 "security" => Ok(RetirementReason::Security),
959 "deprecated" => Ok(RetirementReason::Deprecated),
960 "renamed" => Ok(RetirementReason::Renamed),
961 _ => Err(serde::de::Error::custom("unknown retirement reason type")),
962 }
963 }
964}
965
966impl RetirementReason {
967 pub fn to_str(&self) -> &'static str {
968 match self {
969 RetirementReason::Other => "other",
970 RetirementReason::Invalid => "invalid",
971 RetirementReason::Security => "security",
972 RetirementReason::Deprecated => "deprecated",
973 RetirementReason::Renamed => "renamed",
974 }
975 }
976}
977
978#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
979pub struct Dependency {
980 /// Version requirement of dependency
981 pub requirement: Range,
982 /// If true the package is optional and does not need to be resolved
983 /// unless another package has specified it as a non-optional dependency.
984 pub optional: bool,
985 /// If set is the OTP application name of the dependency, if not set the
986 /// application name is the same as the package name
987 pub app: Option<String>,
988 /// If set, the repository where the dependency is located
989 pub repository: Option<String>,
990}
991
992static USER_AGENT: &str = concat!("Gleam v", env!("CARGO_PKG_VERSION"));
993
994static PACKAGE_PATTERN: OnceLock<Regex> = OnceLock::new();
995static VERSION_PATTERN: OnceLock<Regex> = OnceLock::new();
996
997fn validate_package_and_version(package: &str, version: &str) -> Result<(), ApiError> {
998 let package_pattern = PACKAGE_PATTERN.get_or_init(|| Regex::new(r"^[a-z]\w*$").unwrap());
999 let version_pattern =
1000 VERSION_PATTERN.get_or_init(|| Regex::new(r"^[a-zA-Z-0-9\._-]+$").unwrap());
1001
1002 if !package_pattern.is_match(package) {
1003 return Err(ApiError::InvalidPackageNameFormat(package.to_string()));
1004 }
1005 if !version_pattern.is_match(version) {
1006 return Err(ApiError::InvalidVersionFormat(version.to_string()));
1007 }
1008 Ok(())
1009}
1010
1011// To quote the docs:
1012//
1013// > All resources will be signed by the repository's private key.
1014// > A signed resource is wrapped in a Signed message. The data under
1015// > the payload field is signed by the signature field.
1016// >
1017// > The signature is an (unencoded) RSA signature of the (unencoded)
1018// > SHA-512 digest of the payload.
1019//
1020// https://github.com/hexpm/specifications/blob/master/registry-v2.md#signing
1021//
1022fn verify_payload(mut signed: Signed, pem_public_key: &[u8]) -> Result<Vec<u8>, ApiError> {
1023 let (_, pem) = x509_parser::pem::parse_x509_pem(pem_public_key)
1024 .map_err(|_| ApiError::IncorrectPayloadSignature)?;
1025 let (_, spki) = x509_parser::prelude::SubjectPublicKeyInfo::from_der(&pem.contents)
1026 .map_err(|_| ApiError::IncorrectPayloadSignature)?;
1027 let payload = std::mem::take(&mut signed.payload);
1028 let verification = ring::signature::UnparsedPublicKey::new(
1029 &ring::signature::RSA_PKCS1_2048_8192_SHA512,
1030 &spki.subject_public_key,
1031 )
1032 .verify(payload.as_slice(), signed.signature());
1033
1034 if verification.is_ok() {
1035 Ok(payload)
1036 } else {
1037 Err(ApiError::IncorrectPayloadSignature)
1038 }
1039}
1040
1041/// Create a request to get the information for a package release.
1042///
1043pub fn api_get_package_release_request(
1044 name: &str,
1045 version: &str,
1046 credentials: Option<&Credentials>,
1047 config: &Config,
1048) -> http::Request<Vec<u8>> {
1049 let path = format!("packages/{name}/releases/{version}");
1050 config
1051 .api_request(Method::GET, &path)
1052 .read_credentials(credentials)
1053 .header("accept", "application/json")
1054 .body(vec![])
1055 .expect("get_package_release request")
1056}
1057
1058/// Parse a response to get the information for a package release.
1059///
1060pub fn api_get_package_release_response(
1061 response: http::Response<Vec<u8>>,
1062) -> Result<Release<ReleaseMeta>, ApiError> {
1063 let (parts, body) = response.into_parts();
1064
1065 match parts.status {
1066 StatusCode::OK => Ok(serde_json::from_slice(&body)?),
1067 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
1068 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
1069 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
1070 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
1071 status => Err(ApiError::unexpected_response(status, body)),
1072 }
1073}
1074
1075/// Create a device authorisation, kicking off the Hex oauth flow.
1076pub fn oauth_device_authorisation_request(
1077 hex_oauth_client_id: &str,
1078 client_name: &str,
1079 config: &Config,
1080) -> http::Request<Vec<u8>> {
1081 let body = json!({
1082 "client_id": hex_oauth_client_id,
1083 "scope": "api:write",
1084 "name": client_name,
1085 })
1086 .to_string()
1087 .into_bytes();
1088 config
1089 .api_request(Method::POST, "oauth/device_authorization")
1090 .builder
1091 .body(body)
1092 .expect("oauth_device_authorisation_request")
1093}
1094
1095/// Parse the response of creating a device authorisation, kicking off the Hex oauth flow.
1096pub fn oauth_device_authorisation_response(
1097 hex_oauth_client_id: String,
1098 response: http::Response<Vec<u8>>,
1099) -> Result<OAuthDeviceAuthorisation, ApiError> {
1100 let (parts, body) = response.into_parts();
1101
1102 match parts.status {
1103 StatusCode::OK => (),
1104 StatusCode::TOO_MANY_REQUESTS => return Err(ApiError::RateLimited),
1105 status => return Err(ApiError::unexpected_response(status, body)),
1106 };
1107
1108 let data: DeviceAuthorisationResponseBody = serde_json::from_slice(&body)?;
1109 let poll_interval = Duration::from_secs(data.poll_interval_seconds);
1110 let verification_uri = data
1111 .verification_uri_complete
1112 .unwrap_or(data.verification_uri);
1113
1114 Ok(OAuthDeviceAuthorisation {
1115 user_code: data.user_code,
1116 device_code: data.device_code,
1117 start_time: Instant::now(),
1118 client_id: hex_oauth_client_id,
1119 poll_interval,
1120 verification_uri,
1121 })
1122}
1123
1124#[derive(Debug)]
1125pub struct OAuthDeviceAuthorisation {
1126 /// Show this code to the user for them to match against the one in the Hex UI.
1127 pub user_code: String,
1128 /// Send the user to this URI after showing them the code.
1129 pub verification_uri: String,
1130 client_id: String,
1131 device_code: String,
1132 poll_interval: Duration,
1133 start_time: Instant,
1134}
1135
1136impl OAuthDeviceAuthorisation {
1137 pub fn poll_token_request(&self, config: &Config) -> http::Request<Vec<u8>> {
1138 let body = json!({
1139 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
1140 "client_id": &self.client_id,
1141 "device_code": &self.device_code,
1142 })
1143 .to_string()
1144 .into_bytes();
1145 config
1146 .api_request(Method::POST, "oauth/token")
1147 .builder
1148 .body(body)
1149 .expect("poll_token_request")
1150 }
1151
1152 pub fn poll_token_response(
1153 &mut self,
1154 response: http::Response<Vec<u8>>,
1155 ) -> Result<PollStep, ApiError> {
1156 if self.start_time.elapsed() > Duration::from_mins(10) {
1157 return Err(ApiError::OAuthTimeout);
1158 }
1159
1160 let (parts, body) = response.into_parts();
1161
1162 match parts.status {
1163 StatusCode::OK | StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN => (),
1164 status => return Err(ApiError::unexpected_response(status, body)),
1165 };
1166
1167 let data: PollResponseBody = serde_json::from_slice(&body)?;
1168 match data.into_result() {
1169 Ok(tokens) => Ok(PollStep::Done(tokens)),
1170
1171 Err(PollResponseBodyError::AuthorizationPending) => {
1172 Ok(PollStep::SleepThenPollAgain(self.poll_interval))
1173 }
1174
1175 Err(PollResponseBodyError::SlowDown) => {
1176 let max_interval = Duration::from_secs(30);
1177 self.poll_interval = self.poll_interval.saturating_mul(2).min(max_interval);
1178 Ok(PollStep::SleepThenPollAgain(self.poll_interval))
1179 }
1180
1181 Err(PollResponseBodyError::AccessDenied) => Err(ApiError::OAuthAccessDenied),
1182
1183 Err(PollResponseBodyError::ExpiredToken) => Err(ApiError::ExpiredToken),
1184 }
1185 }
1186}
1187
1188#[derive(Debug)]
1189pub enum PollStep {
1190 Done(OAuthTokens),
1191 SleepThenPollAgain(Duration),
1192}
1193
1194#[derive(Debug, Serialize, Deserialize)]
1195pub struct OAuthTokens {
1196 pub access_token: EcoString,
1197 pub refresh_token: EcoString,
1198}
1199
1200impl OAuthTokens {
1201 pub fn as_credentials(&self) -> Credentials {
1202 Credentials::OAuthAccessToken(self.access_token.clone())
1203 }
1204}
1205
1206#[derive(Debug, Deserialize)]
1207struct DeviceAuthorisationResponseBody {
1208 #[serde(default = "default_poll_interval_seconds", rename = "interval")]
1209 poll_interval_seconds: u64,
1210 device_code: String,
1211 user_code: String,
1212 verification_uri: String,
1213 verification_uri_complete: Option<String>,
1214}
1215
1216#[derive(Debug, Deserialize)]
1217#[serde(untagged)]
1218enum PollResponseBody {
1219 Success {
1220 access_token: EcoString,
1221 refresh_token: EcoString,
1222 },
1223 Fail {
1224 error: PollResponseBodyError,
1225 },
1226}
1227
1228impl PollResponseBody {
1229 pub fn into_result(self) -> Result<OAuthTokens, PollResponseBodyError> {
1230 match self {
1231 PollResponseBody::Success {
1232 access_token,
1233 refresh_token,
1234 } => Ok(OAuthTokens {
1235 access_token,
1236 refresh_token,
1237 }),
1238 PollResponseBody::Fail { error } => Err(error),
1239 }
1240 }
1241}
1242
1243#[derive(Debug, Deserialize)]
1244enum PollResponseBodyError {
1245 #[serde(rename = "authorization_pending")]
1246 AuthorizationPending,
1247 #[serde(rename = "slow_down")]
1248 SlowDown,
1249 #[serde(rename = "access_denied")]
1250 AccessDenied,
1251 #[serde(rename = "expired_token")]
1252 ExpiredToken,
1253}
1254
1255fn default_poll_interval_seconds() -> u64 {
1256 5
1257}
1258
1259/// Use a refresh token to get an access token that can be used to
1260/// authenticate with the API.
1261pub fn oauth_refresh_token_request(
1262 hex_oauth_client_id: &str,
1263 refresh_token: &str,
1264 config: &Config,
1265) -> http::Request<Vec<u8>> {
1266 let body = json!({
1267 "grant_type": "refresh_token",
1268 "client_id": hex_oauth_client_id,
1269 "refresh_token": refresh_token,
1270 })
1271 .to_string()
1272 .into_bytes();
1273 config
1274 .api_request(Method::POST, "oauth/token")
1275 .builder
1276 .body(body)
1277 .expect("oauth_refresh_token_request")
1278}
1279
1280pub fn oauth_refresh_token_response(
1281 response: http::Response<Vec<u8>>,
1282) -> Result<OAuthTokens, ApiError> {
1283 let (parts, body) = response.into_parts();
1284
1285 match parts.status {
1286 StatusCode::OK => (),
1287 StatusCode::TOO_MANY_REQUESTS => return Err(ApiError::RateLimited),
1288 StatusCode::BAD_REQUEST => return Err(ApiError::OAuthRefreshTokenRejected),
1289 status => return Err(ApiError::unexpected_response(status, body)),
1290 };
1291
1292 Ok(serde_json::from_slice(&body)?)
1293}
1294
1295/// Get information about the currently authenticated user.
1296pub fn get_me_request(credentials: &Credentials, config: &Config) -> http::Request<Vec<u8>> {
1297 config
1298 .api_request(Method::GET, "users/me")
1299 .read_credentials(Some(credentials))
1300 .body(vec![])
1301 .expect("me_request")
1302}
1303
1304/// Get information about the currently authenticated user.
1305pub fn get_me_response(response: http::Response<Vec<u8>>) -> Result<Me, ApiError> {
1306 let (parts, body) = response.into_parts();
1307
1308 match parts.status {
1309 StatusCode::OK => (),
1310 status => return Err(ApiError::unexpected_response(status, body)),
1311 };
1312
1313 Ok(serde_json::from_slice(&body)?)
1314}
1315
1316#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
1317pub struct Me {
1318 pub username: String,
1319}
1320
1321/// Create a request to revoke an OAuth token.
1322///
1323pub fn revoke_oauth_token_by_hash_request(
1324 refresh_token_hash: &str,
1325 credentials: &WriteActionCredentials,
1326 config: &Config,
1327) -> http::Request<Vec<u8>> {
1328 let body = json!({
1329 "token_hash": refresh_token_hash,
1330 })
1331 .to_string()
1332 .into_bytes();
1333 config
1334 .api_request(Method::POST, "oauth/revoke_by_hash")
1335 .write_credentials(credentials)
1336 .header("accept", "application/json")
1337 .body(body)
1338 .expect("api_get_package_release_request request")
1339}
1340
1341/// Parse a response to revoke an OAuth token.
1342///
1343pub fn revoke_oauth_token_by_hash_response(
1344 response: http::Response<Vec<u8>>,
1345) -> Result<(), ApiError> {
1346 let (parts, body) = response.into_parts();
1347 match parts.status {
1348 StatusCode::OK => (),
1349 status => return Err(ApiError::unexpected_response(status, body)),
1350 };
1351 Ok(())
1352}