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 1352 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)] 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}