Fork of daniellemaywood.uk/gleam — Wasm codegen work
15 kB
453 lines
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2021 The Gleam contributors
3
4use camino::Utf8Path;
5use clap::ValueEnum;
6use debug_ignore::DebugIgnore;
7use flate2::read::GzDecoder;
8use futures::future;
9use hexpm::{ApiError, WriteActionCredentials, version::Version};
10use strum::{EnumString, VariantNames};
11use tar::Archive;
12
13use crate::{
14 Error, Result,
15 io::{FileSystemReader, FileSystemWriter, HttpClient, TarUnpacker},
16 manifest::{ManifestPackage, ManifestPackageSource},
17 paths::{self, ProjectPaths},
18};
19
20pub const HEXPM_PUBLIC_KEY: &[u8] = b"-----BEGIN PUBLIC KEY-----
21MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApqREcFDt5vV21JVe2QNB
22Edvzk6w36aNFhVGWN5toNJRjRJ6m4hIuG4KaXtDWVLjnvct6MYMfqhC79HAGwyF+
23IqR6Q6a5bbFSsImgBJwz1oadoVKD6ZNetAuCIK84cjMrEFRkELtEIPNHblCzUkkM
243rS9+DPlnfG8hBvGi6tvQIuZmXGCxF/73hU0/MyGhbmEjIKRtG6b0sJYKelRLTPW
25XgK7s5pESgiwf2YC/2MGDXjAJfpfCd0RpLdvd4eRiXtVlE9qO9bND94E7PgQ/xqZ
26J1i2xWFndWa6nfFnRxZmCStCOZWYYPlaxr+FZceFbpMwzTNs4g3d4tLNUcbKAIH4
270wIDAQAB
28-----END PUBLIC KEY-----
29";
30
31fn key_name(hostname: &str) -> String {
32 format!("gleam-{hostname}")
33}
34
35pub async fn publish_package<Http: HttpClient>(
36 release_tarball: Vec<u8>,
37 version: String,
38 name: &str,
39 api_key: &WriteActionCredentials,
40 config: &hexpm::Config,
41 replace: bool,
42 http: &Http,
43) -> Result<()> {
44 tracing::info!("Publishing package, replace: {}", replace);
45 let request = hexpm::api_publish_package_request(release_tarball, api_key, config, replace);
46 let response = http.send(request).await?;
47 hexpm::api_publish_package_response(response).map_err(|e| match e {
48 ApiError::NotReplacing => Error::HexPublishReplaceRequired { version },
49 ApiError::Forbidden => Error::HexPublishAccessDenied {
50 name: name.into(),
51 version,
52 },
53 ApiError::Json(_)
54 | ApiError::Io(_)
55 | ApiError::RateLimited
56 | ApiError::InvalidCredentials
57 | ApiError::UnexpectedResponse(..)
58 | ApiError::InvalidPackageNameFormat(_)
59 | ApiError::IncorrectPayloadSignature
60 | ApiError::InvalidProtobuf(_)
61 | ApiError::InvalidVersionFormat(_)
62 | ApiError::NotFound
63 | ApiError::InvalidVersionRequirementFormat(_)
64 | ApiError::IncorrectChecksum
65 | ApiError::OAuthTimeout
66 | ApiError::OAuthAccessDenied
67 | ApiError::ExpiredToken
68 | ApiError::OAuthRefreshTokenRejected
69 | ApiError::IncorrectOneTimePassword
70 | ApiError::LateDeletion
71 | ApiError::LateModification => Error::hex(e),
72 })
73}
74
75pub async fn add_owner<Http: HttpClient>(
76 api_key: &WriteActionCredentials,
77 package_name: String,
78 new_owner_username_or_email: String,
79 level: hexpm::OwnerLevel,
80 config: &hexpm::Config,
81 http: &Http,
82) -> Result<()> {
83 tracing::info!(
84 "Adding {} as owner of `{}`",
85 new_owner_username_or_email,
86 package_name
87 );
88 let request = hexpm::api_add_owner_request(
89 &package_name,
90 &new_owner_username_or_email,
91 level,
92 api_key,
93 config,
94 );
95 let response = http.send(request).await?;
96 hexpm::api_add_owner_response(response).map_err(Error::hex)
97}
98
99pub async fn transfer_owner<Http: HttpClient>(
100 api_key: &WriteActionCredentials,
101 package_name: String,
102 new_owner_username_or_email: String,
103 config: &hexpm::Config,
104 http: &Http,
105) -> Result<()> {
106 tracing::info!(
107 "Transferring ownership of `{}` to {}",
108 package_name,
109 new_owner_username_or_email
110 );
111 let request = hexpm::api_transfer_owner_request(
112 &package_name,
113 &new_owner_username_or_email,
114 api_key,
115 config,
116 );
117 let response = http.send(request).await?;
118 hexpm::api_transfer_owner_response(response).map_err(Error::hex)
119}
120
121#[derive(Debug, EnumString, VariantNames, ValueEnum, Clone, Copy, PartialEq, Eq)]
122#[strum(serialize_all = "lowercase")]
123pub enum RetirementReason {
124 Other,
125 Invalid,
126 Security,
127 Deprecated,
128 Renamed,
129}
130
131impl RetirementReason {
132 pub fn to_library_enum(&self) -> hexpm::RetirementReason {
133 match self {
134 RetirementReason::Other => hexpm::RetirementReason::Other,
135 RetirementReason::Invalid => hexpm::RetirementReason::Invalid,
136 RetirementReason::Security => hexpm::RetirementReason::Security,
137 RetirementReason::Deprecated => hexpm::RetirementReason::Deprecated,
138 RetirementReason::Renamed => hexpm::RetirementReason::Renamed,
139 }
140 }
141}
142
143pub async fn retire_release<Http: HttpClient>(
144 package: &str,
145 version: &str,
146 reason: RetirementReason,
147 message: Option<&str>,
148 api_key: &WriteActionCredentials,
149 config: &hexpm::Config,
150 http: &Http,
151) -> Result<()> {
152 tracing::info!(package=%package, version=%version, "retiring_hex_release");
153 let request = hexpm::api_retire_release_request(
154 package,
155 version,
156 reason.to_library_enum(),
157 message,
158 api_key,
159 config,
160 );
161 let response = http.send(request).await?;
162 hexpm::api_retire_release_response(response).map_err(Error::hex)
163}
164
165pub async fn unretire_release<Http: HttpClient>(
166 package: &str,
167 version: &str,
168 api_key: &WriteActionCredentials,
169 config: &hexpm::Config,
170 http: &Http,
171) -> Result<()> {
172 tracing::info!(package=%package, version=%version, "retiring_hex_release");
173 let request = hexpm::api_unretire_release_request(package, version, api_key, config);
174 let response = http.send(request).await?;
175 hexpm::api_unretire_release_response(response).map_err(Error::hex)
176}
177
178pub async fn remove_api_key<Http: HttpClient>(
179 hostname: &str,
180 config: &hexpm::Config,
181 auth_key: &WriteActionCredentials,
182 http: &Http,
183) -> Result<()> {
184 tracing::info!("Deleting API key from Hex");
185 let request = hexpm::api_remove_api_key_request(&key_name(hostname), auth_key, config);
186 let response = http.send(request).await?;
187 hexpm::api_remove_api_key_response(response).map_err(Error::hex)
188}
189
190#[derive(Debug)]
191pub struct Downloader {
192 fs_reader: DebugIgnore<Box<dyn FileSystemReader>>,
193 fs_writer: DebugIgnore<Box<dyn FileSystemWriter>>,
194 http: DebugIgnore<Box<dyn HttpClient>>,
195 untar: DebugIgnore<Box<dyn TarUnpacker>>,
196 hex_config: hexpm::Config,
197 credentials: DebugIgnore<Option<hexpm::Credentials>>,
198 paths: ProjectPaths,
199}
200
201impl Downloader {
202 pub fn new(
203 fs_reader: Box<dyn FileSystemReader>,
204 fs_writer: Box<dyn FileSystemWriter>,
205 http: Box<dyn HttpClient>,
206 untar: Box<dyn TarUnpacker>,
207 credentials: Option<hexpm::Credentials>,
208 paths: ProjectPaths,
209 ) -> Self {
210 Self {
211 fs_reader: DebugIgnore(fs_reader),
212 fs_writer: DebugIgnore(fs_writer),
213 http: DebugIgnore(http),
214 untar: DebugIgnore(untar),
215 hex_config: hexpm::Config::new(),
216 credentials: DebugIgnore(credentials),
217 paths,
218 }
219 }
220
221 pub async fn ensure_package_downloaded(
222 &self,
223 package: &ManifestPackage,
224 ) -> Result<bool, Error> {
225 let outer_checksum = match &package.source {
226 ManifestPackageSource::Hex { outer_checksum } => outer_checksum,
227 ManifestPackageSource::Git { .. } | ManifestPackageSource::Local { .. } => {
228 panic!("Attempt to download non-hex package from hex")
229 }
230 };
231
232 let tarball_path = paths::global_package_cache_package_tarball(outer_checksum);
233 if self.fs_reader.is_file(&tarball_path) {
234 tracing::info!(
235 package = package.name.as_str(),
236 version = %package.version,
237 "package_in_cache"
238 );
239 return Ok(false);
240 }
241 tracing::info!(
242 package = &package.name.as_str(),
243 version = %package.version,
244 "downloading_package_to_cache"
245 );
246
247 let request = hexpm::repository_get_package_tarball_request(
248 &package.name,
249 &package.version.to_string(),
250 self.credentials.as_ref(),
251 &self.hex_config,
252 );
253 let response = self.http.send(request).await?;
254
255 let tarball = hexpm::repository_get_package_tarball_response(response, &outer_checksum.0)
256 .map_err(|error| Error::DownloadPackageError {
257 package_name: package.name.to_string(),
258 package_version: package.version.to_string(),
259 error: error.to_string(),
260 })?;
261 self.fs_writer.write_bytes(&tarball_path, &tarball)?;
262 Ok(true)
263 }
264
265 pub async fn ensure_package_in_build_directory(
266 &self,
267 package: &ManifestPackage,
268 ) -> Result<bool> {
269 let _ = self.ensure_package_downloaded(package).await?;
270 self.extract_package_from_cache(package)
271 }
272
273 // It would be really nice if this was async but the library is sync
274 pub fn extract_package_from_cache(&self, package: &ManifestPackage) -> Result<bool> {
275 let contents_path = Utf8Path::new("contents.tar.gz");
276 let destination = self.paths.build_packages_package(&package.name);
277
278 let outer_checksum = match &package.source {
279 ManifestPackageSource::Hex { outer_checksum } => outer_checksum,
280 ManifestPackageSource::Git { .. } | ManifestPackageSource::Local { .. } => {
281 panic!("Attempt to download non-hex package from hex")
282 }
283 };
284
285 // If the directory already exists then there's nothing for us to do
286 if self.fs_reader.is_directory(&destination) {
287 tracing::info!(
288 package = package.name.as_str(),
289 "Package already in build directory"
290 );
291 return Ok(false);
292 }
293
294 tracing::info!(package = package.name.as_str(), "writing_package_to_target");
295 let tarball = paths::global_package_cache_package_tarball(outer_checksum);
296 let reader = self.fs_reader.reader(&tarball)?;
297 let mut archive = Archive::new(reader);
298
299 // Find the source code from within the outer tarball
300 for entry in self.untar.entries(&mut archive)? {
301 let file = entry.map_err(Error::expand_tar)?;
302
303 let path = file.header().path().map_err(Error::expand_tar)?;
304 if path.as_ref() == contents_path {
305 // Expand this inner source code and write to the file system
306 let archive = Archive::new(GzDecoder::new(file));
307 let result = self.untar.unpack(&destination, archive);
308
309 // If we failed to expand the tarball remove any source code
310 // that was partially written so that we don't mistakenly think
311 // the operation succeeded next time we run.
312 return match result {
313 Ok(()) => Ok(true),
314 Err(err) => {
315 self.fs_writer.delete_directory(&destination)?;
316 Err(err)
317 }
318 };
319 }
320 }
321
322 Err(Error::ExpandTar {
323 error: "Unable to locate Hex package contents.tar.gz".into(),
324 })
325 }
326
327 pub async fn download_hex_packages<'a, Packages: Iterator<Item = &'a ManifestPackage>>(
328 &self,
329 packages: Packages,
330 project_name: &str,
331 ) -> Result<()> {
332 let futures = packages
333 .filter(|package| project_name != package.name)
334 .map(|package| self.ensure_package_in_build_directory(package));
335
336 // Run the futures to download the packages concurrently
337 let results = future::join_all(futures).await;
338
339 // Count the number of packages downloaded while checking for errors
340 for result in results {
341 let _ = result?;
342 }
343 Ok(())
344 }
345}
346
347pub async fn publish_documentation<Http: HttpClient>(
348 name: &str,
349 version: &Version,
350 archive: Vec<u8>,
351 api_key: &WriteActionCredentials,
352 config: &hexpm::Config,
353 http: &Http,
354) -> Result<()> {
355 tracing::info!("publishing_documentation");
356 let request =
357 hexpm::api_publish_docs_request(name, &version.to_string(), archive, api_key, config)
358 .map_err(Error::hex)?;
359 let response = http.send(request).await?;
360 hexpm::api_publish_docs_response(response).map_err(Error::hex)
361}
362
363pub async fn get_package_release<Http: HttpClient>(
364 name: &str,
365 version: &Version,
366 credentials: Option<&hexpm::Credentials>,
367 config: &hexpm::Config,
368 http: &Http,
369) -> Result<hexpm::Release<hexpm::ReleaseMeta>> {
370 let version = version.to_string();
371 tracing::info!(
372 name = name,
373 version = version.as_str(),
374 "looking_up_package_release"
375 );
376 let request = hexpm::api_get_package_release_request(name, &version, credentials, config);
377 let response = http.send(request).await?;
378 hexpm::api_get_package_release_response(response).map_err(Error::hex)
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use std::sync::Mutex;
385
386 /// A fake `HttpClient` that records the `authorization` header of the last
387 /// request it was sent and replies with a canned package release.
388 #[derive(Default)]
389 struct AuthorizationCapturingHttpClient {
390 last_authorization: Mutex<Option<String>>,
391 }
392
393 #[async_trait::async_trait]
394 impl HttpClient for AuthorizationCapturingHttpClient {
395 async fn send(
396 &self,
397 request: http::Request<Vec<u8>>,
398 ) -> Result<http::Response<Vec<u8>>, Error> {
399 let authorization = request
400 .headers()
401 .get("authorization")
402 .map(|value| value.to_str().unwrap().to_string());
403 *self.last_authorization.lock().unwrap() = authorization;
404
405 let body = serde_json::json!({
406 "version": "1.0.0",
407 "checksum": "960090c2fb391784bb34267b099dc9315cc1b1f6013e7415bc763cef1905d7d3",
408 "requirements": {},
409 "meta": { "app": "gleam_stdlib", "build_tools": ["gleam"] }
410 })
411 .to_string()
412 .into_bytes();
413
414 Ok(http::Response::builder().status(200).body(body).unwrap())
415 }
416 }
417
418 #[test]
419 fn get_package_release_authenticates_with_api_key() {
420 let http = AuthorizationCapturingHttpClient::default();
421 let credentials = hexpm::Credentials::ApiKey("secret-key".into());
422
423 let _ = futures::executor::block_on(get_package_release(
424 "gleam_stdlib",
425 &Version::new(1, 0, 0),
426 Some(&credentials),
427 &hexpm::Config::new(),
428 &http,
429 ))
430 .unwrap();
431
432 assert_eq!(
433 http.last_authorization.lock().unwrap().as_deref(),
434 Some("secret-key")
435 );
436 }
437
438 #[test]
439 fn get_package_release_is_anonymous_without_api_key() {
440 let http = AuthorizationCapturingHttpClient::default();
441
442 let _ = futures::executor::block_on(get_package_release(
443 "gleam_stdlib",
444 &Version::new(1, 0, 0),
445 None,
446 &hexpm::Config::new(),
447 &http,
448 ))
449 .unwrap();
450
451 assert_eq!(http.last_authorization.lock().unwrap().as_deref(), None);
452 }
453}