Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

gleam / hexpm / src / lib.rs
44 kB 1356 lines
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}