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, PartialEq, Eq)]
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::TOO_MANY_REQUESTS => return Err(ApiError::RateLimited),
374 StatusCode::UNAUTHORIZED => return Err(unauthorised_response(&parts.headers)),
375 StatusCode::FORBIDDEN => return Err(ApiError::NotFound),
376 StatusCode::NOT_FOUND => return Err(ApiError::NotFound),
377 status => {
378 return Err(ApiError::unexpected_response(status, body));
379 }
380 };
381
382 let mut decoder = GzDecoder::new(body.reader());
383 let mut body = Vec::new();
384 decoder.read_to_end(&mut body)?;
385
386 repository_v2_package_parse_body(&body, public_key)
387}
388
389/// Parse a signed binary message containing the information for a package in the repository.
390pub fn repository_v2_package_parse_body(
391 protobuf_bytes: &Vec<u8>,
392 public_key: &[u8],
393) -> Result<Package, ApiError> {
394 let signed = Signed::decode(protobuf_bytes.as_slice())?;
395
396 let payload =
397 verify_payload(signed, public_key).map_err(|_| ApiError::IncorrectPayloadSignature)?;
398
399 let package = proto::package::Package::decode(payload.as_slice())?;
400 let releases = package
401 .releases
402 .clone()
403 .into_iter()
404 .map(proto_to_release)
405 .collect::<Result<Vec<_>, _>>()?;
406 let package = Package {
407 name: package.name,
408 repository: package.repository,
409 releases,
410 };
411
412 Ok(package)
413}
414
415/// Create a request to download a version of a package as a tarball
416/// TODO: Where are the API docs for this?
417pub fn repository_get_package_tarball_request(
418 name: &str,
419 version: &str,
420 credentials: Option<&Credentials>,
421 config: &Config,
422) -> http::Request<Vec<u8>> {
423 config
424 .repository_request(
425 Method::GET,
426 &format!("tarballs/{name}-{version}.tar"),
427 credentials,
428 )
429 .header("accept", "application/x-tar")
430 .body(vec![])
431 .expect("get_package_tarball_request request")
432}
433
434/// Parse a response to download a version of a package as a tarball
435///
436pub fn repository_get_package_tarball_response(
437 response: http::Response<Vec<u8>>,
438 checksum: &[u8],
439) -> Result<Vec<u8>, ApiError> {
440 let (parts, body) = response.into_parts();
441 match parts.status {
442 StatusCode::OK => (),
443 StatusCode::TOO_MANY_REQUESTS => return Err(ApiError::RateLimited),
444 StatusCode::UNAUTHORIZED => return Err(unauthorised_response(&parts.headers)),
445 StatusCode::FORBIDDEN => return Err(ApiError::NotFound),
446 StatusCode::NOT_FOUND => return Err(ApiError::NotFound),
447 status => {
448 return Err(ApiError::unexpected_response(status, body));
449 }
450 };
451 let body = read_and_check_body(body.reader(), checksum)?;
452 Ok(body)
453}
454
455/// API Docs:
456///
457/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L384
458///
459/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release_docs.ex#L19
460pub fn api_remove_docs_request(
461 package_name: &str,
462 version: &str,
463 credentials: &WriteActionCredentials,
464 config: &Config,
465) -> Result<http::Request<Vec<u8>>, ApiError> {
466 validate_package_and_version(package_name, version)?;
467 let path = format!("packages/{package_name}/releases/{version}/docs");
468 Ok(config
469 .api_request(Method::DELETE, &path)
470 .write_credentials(credentials)
471 .body(vec![])
472 .expect("remove_docs_request request"))
473}
474
475pub fn api_remove_docs_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
476 let (parts, body) = response.into_parts();
477 match parts.status {
478 StatusCode::NO_CONTENT => Ok(()),
479 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
480 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
481 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
482 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
483 status => Err(ApiError::unexpected_response(status, body)),
484 }
485}
486
487/// API Docs:
488///
489/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L429
490///
491/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release_docs.ex#L11
492pub fn api_publish_docs_request(
493 package_name: &str,
494 version: &str,
495 gzipped_tarball: Vec<u8>,
496 credentials: &WriteActionCredentials,
497 config: &Config,
498) -> Result<http::Request<Vec<u8>>, ApiError> {
499 validate_package_and_version(package_name, version)?;
500 let path = format!("packages/{package_name}/releases/{version}/docs");
501 let mut builder = config
502 .api_request(Method::POST, &path)
503 .write_credentials(credentials);
504 let headers = builder.headers_mut().expect("headers");
505 headers.insert("content-encoding", "x-gzip".parse().unwrap());
506 headers.insert("content-type", "application/x-tar".parse().unwrap());
507 Ok(builder
508 .body(gzipped_tarball)
509 .expect("publish_docs_request request"))
510}
511
512pub fn api_publish_docs_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
513 let (parts, body) = response.into_parts();
514 match parts.status {
515 StatusCode::CREATED => Ok(()),
516 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
517 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
518 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
519 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
520 status => Err(ApiError::unexpected_response(status, body)),
521 }
522}
523
524/// API Docs:
525///
526/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L512
527///
528/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L13
529pub fn api_publish_package_request(
530 release_tarball: Vec<u8>,
531 credentials: &WriteActionCredentials,
532 config: &Config,
533 replace: bool,
534) -> http::Request<Vec<u8>> {
535 let path = format!("publish?replace={replace}");
536 let mut builder = config
537 .api_request(Method::POST, &path)
538 .write_credentials(credentials);
539 builder
540 .headers_mut()
541 .expect("headers")
542 .insert("content-type", "application/x-tar".parse().unwrap());
543 builder
544 .body(release_tarball)
545 .expect("publish_package_request request")
546}
547
548pub fn api_publish_package_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
549 let (parts, body) = response.into_parts();
550 match parts.status {
551 StatusCode::OK | StatusCode::CREATED => Ok(()),
552 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
553 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
554 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
555 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
556 StatusCode::UNPROCESSABLE_ENTITY => {
557 let body = String::from_utf8_lossy(&body);
558 if body.contains("--replace") {
559 Err(ApiError::NotReplacing)
560 } else if body.contains("can only modify a release up to one hour after publication") {
561 Err(ApiError::LateModification)
562 } else {
563 Err(ApiError::UnexpectedResponse(
564 StatusCode::UNPROCESSABLE_ENTITY,
565 body.to_string(),
566 ))
567 }
568 }
569 status => Err(ApiError::unexpected_response(status, body)),
570 }
571}
572
573/// API Docs:
574///
575/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.publish.ex#L371
576///
577/// https://github.com/hexpm/hex/blob/main/lib/hex/api/release.ex#L21
578pub fn api_revert_release_request(
579 package_name: &str,
580 version: &str,
581 credentials: &WriteActionCredentials,
582 config: &Config,
583) -> Result<http::Request<Vec<u8>>, ApiError> {
584 validate_package_and_version(package_name, version)?;
585 let path = format!("packages/{package_name}/releases/{version}");
586 Ok(config
587 .api_request(Method::DELETE, &path)
588 .write_credentials(credentials)
589 .body(vec![])
590 .expect("publish_package_request request"))
591}
592
593pub fn api_revert_release_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
594 let (parts, body) = response.into_parts();
595 match parts.status {
596 StatusCode::NO_CONTENT => Ok(()),
597 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
598 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
599 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
600 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
601 StatusCode::UNPROCESSABLE_ENTITY if is_late_deletion(&body) => Err(ApiError::LateDeletion),
602 status => Err(ApiError::unexpected_response(status, body)),
603 }
604}
605
606fn is_late_deletion(body: &[u8]) -> bool {
607 String::from_utf8_lossy(body)
608 .contains("can only delete a release up to one hour after publication")
609}
610
611/// See: https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L47
612#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
613pub enum OwnerLevel {
614 /// Has every package permission EXCEPT the ability to change who owns the package
615 Maintainer,
616 /// Has every package permission including the ability to change who owns the package
617 Full,
618}
619
620/// API Docs:
621///
622/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L107
623///
624/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L19
625pub fn api_add_owner_request(
626 package_name: &str,
627 owner: &str,
628 level: OwnerLevel,
629 credentials: &WriteActionCredentials,
630 config: &Config,
631) -> http::Request<Vec<u8>> {
632 let body = json!({
633 "level": owner_level_to_json_string(level),
634 "transfer": false,
635 });
636 let path = format!("packages/{package_name}/owners/{owner}");
637 config
638 .api_request(Method::PUT, &path)
639 .write_credentials(credentials)
640 .body(body.to_string().into_bytes())
641 .expect("add_owner_request request")
642}
643
644/// Turns an `OwnerLevel` into a string that can be used with the json payload
645/// of requests to the Hex API expecting an owner level.
646fn owner_level_to_json_string(level: OwnerLevel) -> &'static str {
647 match level {
648 OwnerLevel::Maintainer => "maintainer",
649 OwnerLevel::Full => "full",
650 }
651}
652
653pub fn api_add_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
654 let (parts, body) = response.into_parts();
655 match parts.status {
656 StatusCode::NO_CONTENT => Ok(()),
657 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
658 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
659 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
660 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
661 status => Err(ApiError::unexpected_response(status, body)),
662 }
663}
664
665/// API Docs:
666///
667/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L125
668///
669/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L19
670pub fn api_transfer_owner_request(
671 package_name: &str,
672 owner: &str,
673 credentials: &WriteActionCredentials,
674 config: &Config,
675) -> http::Request<Vec<u8>> {
676 let body = json!({
677 "level": owner_level_to_json_string(OwnerLevel::Full),
678 "transfer": true,
679 });
680 let path = format!("packages/{package_name}/owners/{owner}");
681 config
682 .api_request(Method::PUT, &path)
683 .write_credentials(credentials)
684 .body(body.to_string().into_bytes())
685 .expect("transfer_owner_request request")
686}
687
688pub fn api_transfer_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
689 let (parts, body) = response.into_parts();
690 match parts.status {
691 StatusCode::NO_CONTENT => Ok(()),
692 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
693 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
694 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
695 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
696 status => Err(ApiError::unexpected_response(status, body)),
697 }
698}
699
700/// API Docs:
701///
702/// https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.owner.ex#L139
703///
704/// https://github.com/hexpm/hex/blob/main/lib/hex/api/package.ex#L28
705pub fn api_remove_owner_request(
706 package_name: &str,
707 owner: &str,
708 credentials: &WriteActionCredentials,
709 config: &Config,
710) -> http::Request<Vec<u8>> {
711 let path = format!("packages/{package_name}/owners/{owner}");
712 config
713 .api_request(Method::DELETE, &path)
714 .write_credentials(credentials)
715 .body(vec![])
716 .expect("remove_owner_request request")
717}
718
719pub fn api_remove_owner_response(response: http::Response<Vec<u8>>) -> Result<(), ApiError> {
720 let (parts, body) = response.into_parts();
721 match parts.status {
722 StatusCode::NO_CONTENT => Ok(()),
723 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
724 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
725 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
726 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
727 status => Err(ApiError::unexpected_response(status, body)),
728 }
729}
730
731#[derive(Error, Debug)]
732pub enum ApiError {
733 #[error(transparent)]
734 Json(#[from] serde_json::Error),
735
736 #[error(transparent)]
737 Io(#[from] std::io::Error),
738
739 #[error("The rate limit for the Hex API has been exceeded")]
740 RateLimited,
741
742 #[error("Invalid authentication credentials")]
743 InvalidCredentials,
744
745 #[error("An unexpected response was sent by Hex: {0}: {1}")]
746 UnexpectedResponse(StatusCode, String),
747
748 #[error("The given package name {0} is not valid")]
749 InvalidPackageNameFormat(String),
750
751 #[error("The payload signature does not match the downloaded payload")]
752 IncorrectPayloadSignature,
753
754 #[error(transparent)]
755 InvalidProtobuf(#[from] prost::DecodeError),
756
757 #[error("Unexpected version format {0}")]
758 InvalidVersionFormat(String),
759
760 #[error("Resource was not found")]
761 NotFound,
762
763 #[error("The version requirement format {0} is not valid")]
764 InvalidVersionRequirementFormat(String),
765
766 #[error("The downloaded data did not have the expected checksum")]
767 IncorrectChecksum,
768
769 #[error("This account is not authorized for this action")]
770 Forbidden,
771
772 #[error("Must explicitly express your intention to replace the release")]
773 NotReplacing,
774
775 #[error("Can only modify a release up to one hour after publication")]
776 LateModification,
777
778 #[error("Can only delete a release up to one hour after publication")]
779 LateDeletion,
780
781 #[error("The oauth request wasn't approved in time")]
782 OAuthTimeout,
783
784 #[error("The oauth request was rejected")]
785 OAuthAccessDenied,
786
787 #[error("The oauth token expired before the request was approved")]
788 ExpiredToken,
789
790 #[error("The oauth refresh token was expired, revoked, or already used")]
791 OAuthRefreshTokenRejected,
792
793 #[error("The supplied one-time-password was not correct")]
794 IncorrectOneTimePassword,
795}
796
797impl ApiError {
798 fn unexpected_response(status: StatusCode, body: Vec<u8>) -> Self {
799 ApiError::UnexpectedResponse(status, String::from_utf8_lossy(&body).to_string())
800 }
801
802 /// Returns `true` if the api error is [`NotFound`].
803 ///
804 /// [`NotFound`]: ApiError::NotFound
805 pub fn is_not_found(&self) -> bool {
806 matches!(self, Self::NotFound)
807 }
808
809 pub fn is_invalid_protobuf(&self) -> bool {
810 matches!(self, Self::InvalidProtobuf(_))
811 }
812}
813
814/// Read a body and ensure it has the given sha256 digest.
815fn read_and_check_body(reader: impl std::io::Read, checksum: &[u8]) -> Result<Vec<u8>, ApiError> {
816 use std::io::Read;
817 let mut reader = BufReader::new(reader);
818 let mut context = Context::new(&SHA256);
819 let mut buffer = [0; 1024];
820 let mut body = Vec::new();
821
822 loop {
823 let count = reader.read(&mut buffer)?;
824 if count == 0 {
825 break;
826 }
827 let bytes = &buffer[..count];
828 context.update(bytes);
829 body.extend_from_slice(bytes);
830 }
831
832 let digest = context.finish();
833 if digest.as_ref() == checksum {
834 Ok(body)
835 } else {
836 Err(ApiError::IncorrectChecksum)
837 }
838}
839
840fn proto_to_retirement_status(
841 status: Option<proto::package::RetirementStatus>,
842) -> Option<RetirementStatus> {
843 status.map(|stat| RetirementStatus {
844 message: stat.message().into(),
845 reason: proto_to_retirement_reason(stat.reason()),
846 })
847}
848
849fn proto_to_retirement_reason(reason: proto::package::RetirementReason) -> RetirementReason {
850 use proto::package::RetirementReason::*;
851 match reason {
852 RetiredOther => RetirementReason::Other,
853 RetiredInvalid => RetirementReason::Invalid,
854 RetiredSecurity => RetirementReason::Security,
855 RetiredDeprecated => RetirementReason::Deprecated,
856 RetiredRenamed => RetirementReason::Renamed,
857 }
858}
859
860fn proto_to_dep(dep: proto::package::Dependency) -> Result<(String, Dependency), ApiError> {
861 let app = dep.app;
862 let repository = dep.repository;
863 let requirement = Range::new(dep.requirement.clone())
864 .map_err(|_| ApiError::InvalidVersionFormat(dep.requirement))?;
865 Ok((
866 dep.package,
867 Dependency {
868 requirement,
869 optional: dep.optional.is_some(),
870 app,
871 repository,
872 },
873 ))
874}
875
876fn proto_to_release(release: proto::package::Release) -> Result<Release<()>, ApiError> {
877 let dependencies = release
878 .dependencies
879 .clone()
880 .into_iter()
881 .map(proto_to_dep)
882 .collect::<Result<HashMap<_, _>, _>>()?;
883 let version = Version::try_from(release.version.as_str())
884 .expect("Failed to parse version format from Hex");
885 Ok(Release {
886 version,
887 outer_checksum: release.outer_checksum.unwrap_or_default(),
888 retirement_status: proto_to_retirement_status(release.retired),
889 requirements: dependencies,
890 meta: (),
891 })
892}
893
894#[derive(Debug, PartialEq, Eq, Clone)]
895pub struct Package {
896 pub name: String,
897 pub repository: String,
898 pub releases: Vec<Release<()>>,
899}
900
901#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
902pub struct Release<Meta> {
903 /// Release version
904 pub version: Version,
905 /// All dependencies of the release
906 pub requirements: HashMap<String, Dependency>,
907 /// If set the release is retired, a retired release should only be
908 /// resolved if it has already been locked in a project
909 pub retirement_status: Option<RetirementStatus>,
910 /// sha256 checksum of outer package tarball
911 /// required when encoding but optional when decoding
912 #[serde(alias = "checksum", deserialize_with = "deserialize_checksum")]
913 pub outer_checksum: Vec<u8>,
914 /// This is not present in all API endpoints so may be absent sometimes.
915 pub meta: Meta,
916}
917
918fn deserialize_checksum<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
919where
920 D: serde::Deserializer<'de>,
921{
922 let s: &str = serde::de::Deserialize::deserialize(deserializer)?;
923 base16::decode(s).map_err(serde::de::Error::custom)
924}
925
926impl<Meta> Release<Meta> {
927 pub fn is_retired(&self) -> bool {
928 self.retirement_status.is_some()
929 }
930}
931
932#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
933pub struct ReleaseMeta {
934 pub app: String,
935 pub build_tools: Vec<String>,
936}
937
938#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
939pub struct RetirementStatus {
940 pub reason: RetirementReason,
941 pub message: String,
942}
943
944#[derive(Debug, PartialEq, Eq, Clone)]
945pub enum RetirementReason {
946 Other,
947 Invalid,
948 Security,
949 Deprecated,
950 Renamed,
951}
952
953impl<'de> serde::Deserialize<'de> for RetirementReason {
954 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
955 where
956 D: serde::Deserializer<'de>,
957 {
958 let s: &str = serde::de::Deserialize::deserialize(deserializer)?;
959 match s {
960 "other" => Ok(RetirementReason::Other),
961 "invalid" => Ok(RetirementReason::Invalid),
962 "security" => Ok(RetirementReason::Security),
963 "deprecated" => Ok(RetirementReason::Deprecated),
964 "renamed" => Ok(RetirementReason::Renamed),
965 _ => Err(serde::de::Error::custom("unknown retirement reason type")),
966 }
967 }
968}
969
970impl RetirementReason {
971 pub fn to_str(&self) -> &'static str {
972 match self {
973 RetirementReason::Other => "other",
974 RetirementReason::Invalid => "invalid",
975 RetirementReason::Security => "security",
976 RetirementReason::Deprecated => "deprecated",
977 RetirementReason::Renamed => "renamed",
978 }
979 }
980}
981
982#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
983pub struct Dependency {
984 /// Version requirement of dependency
985 pub requirement: Range,
986 /// If true the package is optional and does not need to be resolved
987 /// unless another package has specified it as a non-optional dependency.
988 pub optional: bool,
989 /// If set is the OTP application name of the dependency, if not set the
990 /// application name is the same as the package name
991 pub app: Option<String>,
992 /// If set, the repository where the dependency is located
993 pub repository: Option<String>,
994}
995
996static USER_AGENT: &str = concat!("Gleam v", env!("CARGO_PKG_VERSION"));
997
998static PACKAGE_PATTERN: OnceLock<Regex> = OnceLock::new();
999static VERSION_PATTERN: OnceLock<Regex> = OnceLock::new();
1000
1001fn validate_package_and_version(package: &str, version: &str) -> Result<(), ApiError> {
1002 let package_pattern = PACKAGE_PATTERN.get_or_init(|| Regex::new(r"^[a-z]\w*$").unwrap());
1003 let version_pattern =
1004 VERSION_PATTERN.get_or_init(|| Regex::new(r"^[a-zA-Z-0-9\._-]+$").unwrap());
1005
1006 if !package_pattern.is_match(package) {
1007 return Err(ApiError::InvalidPackageNameFormat(package.to_string()));
1008 }
1009 if !version_pattern.is_match(version) {
1010 return Err(ApiError::InvalidVersionFormat(version.to_string()));
1011 }
1012 Ok(())
1013}
1014
1015// To quote the docs:
1016//
1017// > All resources will be signed by the repository's private key.
1018// > A signed resource is wrapped in a Signed message. The data under
1019// > the payload field is signed by the signature field.
1020// >
1021// > The signature is an (unencoded) RSA signature of the (unencoded)
1022// > SHA-512 digest of the payload.
1023//
1024// https://github.com/hexpm/specifications/blob/master/registry-v2.md#signing
1025//
1026fn verify_payload(mut signed: Signed, pem_public_key: &[u8]) -> Result<Vec<u8>, ApiError> {
1027 let (_, pem) = x509_parser::pem::parse_x509_pem(pem_public_key)
1028 .map_err(|_| ApiError::IncorrectPayloadSignature)?;
1029 let (_, spki) = x509_parser::prelude::SubjectPublicKeyInfo::from_der(&pem.contents)
1030 .map_err(|_| ApiError::IncorrectPayloadSignature)?;
1031 let payload = std::mem::take(&mut signed.payload);
1032 let verification = ring::signature::UnparsedPublicKey::new(
1033 &ring::signature::RSA_PKCS1_2048_8192_SHA512,
1034 &spki.subject_public_key,
1035 )
1036 .verify(payload.as_slice(), signed.signature());
1037
1038 if verification.is_ok() {
1039 Ok(payload)
1040 } else {
1041 Err(ApiError::IncorrectPayloadSignature)
1042 }
1043}
1044
1045/// Create a request to get the information for a package release.
1046///
1047pub fn api_get_package_release_request(
1048 name: &str,
1049 version: &str,
1050 credentials: Option<&Credentials>,
1051 config: &Config,
1052) -> http::Request<Vec<u8>> {
1053 let path = format!("packages/{name}/releases/{version}");
1054 config
1055 .api_request(Method::GET, &path)
1056 .read_credentials(credentials)
1057 .header("accept", "application/json")
1058 .body(vec![])
1059 .expect("get_package_release request")
1060}
1061
1062/// Parse a response to get the information for a package release.
1063///
1064pub fn api_get_package_release_response(
1065 response: http::Response<Vec<u8>>,
1066) -> Result<Release<ReleaseMeta>, ApiError> {
1067 let (parts, body) = response.into_parts();
1068
1069 match parts.status {
1070 StatusCode::OK => Ok(serde_json::from_slice(&body)?),
1071 StatusCode::NOT_FOUND => Err(ApiError::NotFound),
1072 StatusCode::TOO_MANY_REQUESTS => Err(ApiError::RateLimited),
1073 StatusCode::UNAUTHORIZED => Err(unauthorised_response(&parts.headers)),
1074 StatusCode::FORBIDDEN => Err(ApiError::Forbidden),
1075 status => Err(ApiError::unexpected_response(status, body)),
1076 }
1077}
1078
1079/// Create a device authorisation, kicking off the Hex oauth flow.
1080pub fn oauth_device_authorisation_request(
1081 hex_oauth_client_id: &str,
1082 client_name: &str,
1083 config: &Config,
1084) -> http::Request<Vec<u8>> {
1085 let body = json!({
1086 "client_id": hex_oauth_client_id,
1087 "scope": "api:write",
1088 "name": client_name,
1089 })
1090 .to_string()
1091 .into_bytes();
1092 config
1093 .api_request(Method::POST, "oauth/device_authorization")
1094 .builder
1095 .body(body)
1096 .expect("oauth_device_authorisation_request")
1097}
1098
1099/// Parse the response of creating a device authorisation, kicking off the Hex oauth flow.
1100pub fn oauth_device_authorisation_response(
1101 hex_oauth_client_id: String,
1102 response: http::Response<Vec<u8>>,
1103) -> Result<OAuthDeviceAuthorisation, ApiError> {
1104 let (parts, body) = response.into_parts();
1105
1106 match parts.status {
1107 StatusCode::OK => (),
1108 StatusCode::TOO_MANY_REQUESTS => return Err(ApiError::RateLimited),
1109 status => return Err(ApiError::unexpected_response(status, body)),
1110 };
1111
1112 let data: DeviceAuthorisationResponseBody = serde_json::from_slice(&body)?;
1113 let poll_interval = Duration::from_secs(data.poll_interval_seconds);
1114 let verification_uri = data
1115 .verification_uri_complete
1116 .unwrap_or(data.verification_uri);
1117
1118 Ok(OAuthDeviceAuthorisation {
1119 user_code: data.user_code,
1120 device_code: data.device_code,
1121 start_time: Instant::now(),
1122 client_id: hex_oauth_client_id,
1123 poll_interval,
1124 verification_uri,
1125 })
1126}
1127
1128#[derive(Debug)]
1129pub struct OAuthDeviceAuthorisation {
1130 /// Show this code to the user for them to match against the one in the Hex UI.
1131 pub user_code: String,
1132 /// Send the user to this URI after showing them the code.
1133 pub verification_uri: String,
1134 client_id: String,
1135 device_code: String,
1136 poll_interval: Duration,
1137 start_time: Instant,
1138}
1139
1140impl OAuthDeviceAuthorisation {
1141 pub fn poll_token_request(&self, config: &Config) -> http::Request<Vec<u8>> {
1142 let body = json!({
1143 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
1144 "client_id": &self.client_id,
1145 "device_code": &self.device_code,
1146 })
1147 .to_string()
1148 .into_bytes();
1149 config
1150 .api_request(Method::POST, "oauth/token")
1151 .builder
1152 .body(body)
1153 .expect("poll_token_request")
1154 }
1155
1156 pub fn poll_token_response(
1157 &mut self,
1158 response: http::Response<Vec<u8>>,
1159 ) -> Result<PollStep, ApiError> {
1160 if self.start_time.elapsed() > Duration::from_mins(10) {
1161 return Err(ApiError::OAuthTimeout);
1162 }
1163
1164 let (parts, body) = response.into_parts();
1165
1166 match parts.status {
1167 StatusCode::OK | StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN => (),
1168 status => return Err(ApiError::unexpected_response(status, body)),
1169 };
1170
1171 let data: PollResponseBody = serde_json::from_slice(&body)?;
1172 match data.into_result() {
1173 Ok(tokens) => Ok(PollStep::Done(tokens)),
1174
1175 Err(PollResponseBodyError::AuthorizationPending) => {
1176 Ok(PollStep::SleepThenPollAgain(self.poll_interval))
1177 }
1178
1179 Err(PollResponseBodyError::SlowDown) => {
1180 let max_interval = Duration::from_secs(30);
1181 self.poll_interval = self.poll_interval.saturating_mul(2).min(max_interval);
1182 Ok(PollStep::SleepThenPollAgain(self.poll_interval))
1183 }
1184
1185 Err(PollResponseBodyError::AccessDenied) => Err(ApiError::OAuthAccessDenied),
1186
1187 Err(PollResponseBodyError::ExpiredToken) => Err(ApiError::ExpiredToken),
1188 }
1189 }
1190}
1191
1192#[derive(Debug)]
1193pub enum PollStep {
1194 Done(OAuthTokens),
1195 SleepThenPollAgain(Duration),
1196}
1197
1198#[derive(Debug, Serialize, Deserialize)]
1199pub struct OAuthTokens {
1200 pub access_token: EcoString,
1201 pub refresh_token: EcoString,
1202}
1203
1204impl OAuthTokens {
1205 pub fn as_credentials(&self) -> Credentials {
1206 Credentials::OAuthAccessToken(self.access_token.clone())
1207 }
1208}
1209
1210#[derive(Debug, Deserialize)]
1211struct DeviceAuthorisationResponseBody {
1212 #[serde(default = "default_poll_interval_seconds", rename = "interval")]
1213 poll_interval_seconds: u64,
1214 device_code: String,
1215 user_code: String,
1216 verification_uri: String,
1217 verification_uri_complete: Option<String>,
1218}
1219
1220#[derive(Debug, Deserialize)]
1221#[serde(untagged)]
1222enum PollResponseBody {
1223 Success {
1224 access_token: EcoString,
1225 refresh_token: EcoString,
1226 },
1227 Fail {
1228 error: PollResponseBodyError,
1229 },
1230}
1231
1232impl PollResponseBody {
1233 pub fn into_result(self) -> Result<OAuthTokens, PollResponseBodyError> {
1234 match self {
1235 PollResponseBody::Success {
1236 access_token,
1237 refresh_token,
1238 } => Ok(OAuthTokens {
1239 access_token,
1240 refresh_token,
1241 }),
1242 PollResponseBody::Fail { error } => Err(error),
1243 }
1244 }
1245}
1246
1247#[derive(Debug, Deserialize)]
1248enum PollResponseBodyError {
1249 #[serde(rename = "authorization_pending")]
1250 AuthorizationPending,
1251 #[serde(rename = "slow_down")]
1252 SlowDown,
1253 #[serde(rename = "access_denied")]
1254 AccessDenied,
1255 #[serde(rename = "expired_token")]
1256 ExpiredToken,
1257}
1258
1259fn default_poll_interval_seconds() -> u64 {
1260 5
1261}
1262
1263/// Use a refresh token to get an access token that can be used to
1264/// authenticate with the API.
1265pub fn oauth_refresh_token_request(
1266 hex_oauth_client_id: &str,
1267 refresh_token: &str,
1268 config: &Config,
1269) -> http::Request<Vec<u8>> {
1270 let body = json!({
1271 "grant_type": "refresh_token",
1272 "client_id": hex_oauth_client_id,
1273 "refresh_token": refresh_token,
1274 })
1275 .to_string()
1276 .into_bytes();
1277 config
1278 .api_request(Method::POST, "oauth/token")
1279 .builder
1280 .body(body)
1281 .expect("oauth_refresh_token_request")
1282}
1283
1284pub fn oauth_refresh_token_response(
1285 response: http::Response<Vec<u8>>,
1286) -> Result<OAuthTokens, ApiError> {
1287 let (parts, body) = response.into_parts();
1288
1289 match parts.status {
1290 StatusCode::OK => (),
1291 StatusCode::TOO_MANY_REQUESTS => return Err(ApiError::RateLimited),
1292 StatusCode::BAD_REQUEST => return Err(ApiError::OAuthRefreshTokenRejected),
1293 status => return Err(ApiError::unexpected_response(status, body)),
1294 };
1295
1296 Ok(serde_json::from_slice(&body)?)
1297}
1298
1299/// Get information about the currently authenticated user.
1300pub fn get_me_request(credentials: &Credentials, config: &Config) -> http::Request<Vec<u8>> {
1301 config
1302 .api_request(Method::GET, "users/me")
1303 .read_credentials(Some(credentials))
1304 .body(vec![])
1305 .expect("me_request")
1306}
1307
1308/// Get information about the currently authenticated user.
1309pub fn get_me_response(response: http::Response<Vec<u8>>) -> Result<Me, ApiError> {
1310 let (parts, body) = response.into_parts();
1311
1312 match parts.status {
1313 StatusCode::OK => (),
1314 status => return Err(ApiError::unexpected_response(status, body)),
1315 };
1316
1317 Ok(serde_json::from_slice(&body)?)
1318}
1319
1320#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize)]
1321pub struct Me {
1322 pub username: String,
1323}
1324
1325/// Create a request to revoke an OAuth token.
1326///
1327pub fn revoke_oauth_token_by_hash_request(
1328 refresh_token_hash: &str,
1329 credentials: &WriteActionCredentials,
1330 config: &Config,
1331) -> http::Request<Vec<u8>> {
1332 let body = json!({
1333 "token_hash": refresh_token_hash,
1334 })
1335 .to_string()
1336 .into_bytes();
1337 config
1338 .api_request(Method::POST, "oauth/revoke_by_hash")
1339 .write_credentials(credentials)
1340 .header("accept", "application/json")
1341 .body(body)
1342 .expect("api_get_package_release_request request")
1343}
1344
1345/// Parse a response to revoke an OAuth token.
1346///
1347pub fn revoke_oauth_token_by_hash_response(
1348 response: http::Response<Vec<u8>>,
1349) -> Result<(), ApiError> {
1350 let (parts, body) = response.into_parts();
1351 match parts.status {
1352 StatusCode::OK => (),
1353 status => return Err(ApiError::unexpected_response(status, body)),
1354 };
1355 Ok(())
1356}