1use crate::data::manifest::{Manifest, MANIFEST_FILE_NAME};
4use crate::database;
5use crate::graphql::execute_query_modifier;
6use crate::keys;
7use crate::util::create_temp_dir;
8use crate::validate;
9
10use console::{style, Emoji};
11use flate2::{write::GzEncoder, Compression};
12use graphql_client::*;
13use rpassword_wasi as rpassword;
14use structopt::StructOpt;
15use tar::Builder;
16use thiserror::Error;
17
18use std::collections::BTreeMap;
19use std::fmt::Write;
20use std::fs;
21use std::io::{BufRead, Write as IoWrite};
22use std::path::{Path, PathBuf};
23
24use wapm_toml::Package;
25
26static UPLOAD: Emoji<'_, '_> = Emoji("⬆️ ", "");
27static PACKAGE: Emoji<'_, '_> = Emoji("📦 ", "");
28
29#[derive(StructOpt, Debug)]
30pub struct PublishOpt {
31 #[structopt(long = "dry-run")]
33 dry_run: bool,
34 #[structopt(long = "quiet")]
35 quiet: bool,
36}
37
38#[derive(GraphQLQuery)]
39#[graphql(
40 schema_path = "graphql/schema.graphql",
41 query_path = "graphql/queries/publish_package_chunked.graphql",
42 response_derives = "Debug"
43)]
44struct PublishPackageMutationChunked;
45
46#[derive(GraphQLQuery)]
47#[graphql(
48 schema_path = "graphql/schema.graphql",
49 query_path = "graphql/queries/publish_package.graphql",
50 response_derives = "Debug, Clone"
51)]
52struct PublishPackageMutation;
53
54#[derive(GraphQLQuery)]
55#[graphql(
56 schema_path = "graphql/schema.graphql",
57 query_path = "graphql/queries/get_signed_url.graphql",
58 response_derives = "Debug, Clone"
59)]
60struct GetSignedUrl;
61
62fn normalize_path(cwd: &Path, path: &Path) -> PathBuf {
63 let mut out = PathBuf::from(cwd);
64 let mut components = path.components();
65 if path.is_absolute() {
66 warn!(
67 "Interpreting absolute path {} as a relative path",
68 path.to_string_lossy()
69 );
70 components.next();
71 }
72 for comp in components {
73 out.push(comp);
74 }
75 out
76}
77
78pub fn publish(publish_opts: PublishOpt) -> anyhow::Result<()> {
79 let mut builder = Builder::new(Vec::new());
80 let cwd = crate::config::Config::get_current_dir()?;
81
82 validate::validate_directory(cwd.clone())?;
83
84 let manifest = Manifest::find_in_directory(&cwd)?;
85
86 let manifest_path_buf = cwd.join(MANIFEST_FILE_NAME);
87 builder.append_path_with_name(&manifest_path_buf, MANIFEST_FILE_NAME)?;
88 let package = &manifest.package;
89 let modules = manifest.module.as_ref().ok_or(PublishError::NoModule)?;
90 let manifest_string = toml::to_string(&manifest)?;
91
92 let readme = package.readme.as_ref().and_then(|readme_path| {
93 let normalized_path = normalize_path(&manifest.base_directory_path, readme_path);
94 if builder.append_path(&normalized_path).is_err() {
95 }
97 fs::read_to_string(normalized_path).ok()
98 });
99 let license_file = package.license_file.as_ref().and_then(|license_file_path| {
100 let normalized_path = normalize_path(&manifest.base_directory_path, license_file_path);
101 if builder.append_path(&normalized_path).is_err() {
102 }
104 fs::read_to_string(normalized_path).ok()
105 });
106
107 for module in modules {
108 let normalized_path = normalize_path(&manifest.base_directory_path, &module.source);
109 normalized_path
110 .metadata()
111 .map_err(|_| PublishError::SourceMustBeFile {
112 module: module.name.clone(),
113 path: normalized_path.clone(),
114 })?;
115 builder
116 .append_path(normalized_path)
117 .map_err(|_| PublishError::ErrorBuildingPackage(module.name.clone()))?;
118
119 if let Some(bindings) = &module.bindings {
120 for path in bindings.referenced_files(&manifest.base_directory_path)? {
121 let normalized_path = normalize_path(&manifest.base_directory_path, &path);
122 normalized_path
123 .metadata()
124 .map_err(|_| PublishError::MissingBindings {
125 module: module.name.clone(),
126 path: normalized_path.clone(),
127 })?;
128 builder
129 .append_path(normalized_path)
130 .map_err(|_| PublishError::ErrorBuildingPackage(module.name.clone()))?;
131 }
132 }
133 }
134
135 for (_alias, path) in manifest.fs.unwrap_or_default().iter() {
137 let normalized_path = normalize_path(&cwd, path);
138 let path_metadata = normalized_path.metadata().map_err(|_| {
139 PublishError::MissingManifestFsPath(normalized_path.to_string_lossy().to_string())
140 })?;
141 if path_metadata.is_dir() {
142 builder.append_dir_all(path, &normalized_path)
143 } else {
144 return Err(PublishError::PackageFileSystemEntryMustBeDirectory(
145 path.to_string_lossy().to_string(),
146 )
147 .into());
148 }
149 .map_err(|_| {
150 PublishError::MissingManifestFsPath(normalized_path.to_string_lossy().to_string())
151 })?;
152 }
153
154 builder.finish().ok();
155 let tar_archive_data = builder.into_inner().map_err(|_|
156 PublishError::NoModule)?;
158 let archive_name = "package.tar.gz".to_string();
159 let archive_dir = create_temp_dir()?;
160 let archive_dir_path: &std::path::Path = archive_dir.as_ref();
161 fs::create_dir(archive_dir_path.join("wapm_package"))?;
162 let archive_path = archive_dir_path.join("wapm_package").join(&archive_name);
163 let mut compressed_archive = fs::File::create(&archive_path).unwrap();
164 let mut gz_enc = GzEncoder::new(&mut compressed_archive, Compression::best());
165
166 gz_enc.write_all(&tar_archive_data).unwrap();
167 let _compressed_archive = gz_enc.finish().unwrap();
168 let mut compressed_archive_reader = fs::File::open(&archive_path)?;
169
170 let maybe_signature_data = sign_compressed_archive(&mut compressed_archive_reader)?;
171 let archived_data_size = archive_path.metadata()?.len();
172
173 assert!(archive_path.exists());
174 assert!(archive_path.is_file());
175
176 if publish_opts.dry_run {
177 println!(
180 "Successfully published package `{}@{}`",
181 package.name, package.version
182 );
183
184 info!(
185 "Publish succeeded, but package was not published because it was run in dry-run mode"
186 );
187
188 return Ok(());
189 }
190
191 try_chunked_uploading(
192 package,
193 &manifest_string,
194 &license_file,
195 &readme,
196 &archive_name,
197 &archive_path,
198 &maybe_signature_data,
199 archived_data_size,
200 publish_opts.quiet,
201 )
202 .map_err(on_error)
203}
204
205#[allow(clippy::too_many_arguments)]
206fn try_chunked_uploading(
207 package: &Package,
208 manifest_string: &String,
209 license_file: &Option<String>,
210 readme: &Option<String>,
211 archive_name: &String,
212 archive_path: &PathBuf,
213 maybe_signature_data: &SignArchiveResult,
214 archived_data_size: u64,
215 quiet: bool,
216) -> Result<(), anyhow::Error> {
217 let maybe_signature_data = match maybe_signature_data {
218 SignArchiveResult::Ok {
219 public_key_id,
220 signature,
221 } => {
222 info!(
223 "Package successfully signed with public key: \"{}\"!",
224 &public_key_id
225 );
226 Some(publish_package_mutation_chunked::InputSignature {
227 public_key_key_id: public_key_id.to_string(),
228 data: signature.to_string(),
229 })
230 }
231 SignArchiveResult::NoKeyRegistered => {
232 None
235 }
236 };
237
238 if !quiet {
239 println!("{} {} Uploading...", style("[1/2]").bold().dim(), UPLOAD);
240 }
241
242 let get_google_signed_url = GetSignedUrl::build_query(get_signed_url::Variables {
243 name: package.name.to_string(),
244 version: package.version.to_string(),
245 expires_after_seconds: Some(60 * 30),
246 });
247
248 let _response: get_signed_url::ResponseData =
249 execute_query_modifier(&get_google_signed_url, |f| f)?;
250
251 let url = _response.url.ok_or_else(|| {
252 anyhow!(
253 "could not get signed url for package {}@{}",
254 package.name,
255 package.version
256 )
257 })?;
258
259 let signed_url = url.url;
260 let url = url::Url::parse(&signed_url).unwrap();
261 let client = reqwest::blocking::Client::builder()
262 .default_headers(reqwest::header::HeaderMap::default())
263 .build()
264 .unwrap();
265
266 let res = client
267 .post(url)
268 .header(reqwest::header::CONTENT_LENGTH, "0")
269 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
270 .header("x-goog-resumable", "start");
271
272 let result = res.send().unwrap();
273
274 if result.status() != reqwest::StatusCode::from_u16(201).unwrap() {
275 return Err(anyhow!(
276 "Uploading package failed: got HTTP {:?} when uploading",
277 result.status()
278 ));
279 }
280
281 let headers = result
282 .headers()
283 .into_iter()
284 .filter_map(|(k, v)| {
285 let k = k.to_string();
286 let v = v.to_str().ok()?.to_string();
287 Some((k.to_lowercase(), v))
288 })
289 .collect::<BTreeMap<_, _>>();
290
291 let session_uri = headers.get("location").unwrap().clone();
292
293 let total = archived_data_size;
294
295 use indicatif::{ProgressBar, ProgressState, ProgressStyle};
296
297 let mut file = std::fs::OpenOptions::new()
299 .read(true)
300 .open(archive_path)
301 .map_err(|e| anyhow!("cannot open archive {}: {e}", archive_path.display()))?;
302
303 let pb = ProgressBar::new(archived_data_size);
304 pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")
305 .unwrap()
306 .with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
307 write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
308 })
309 .progress_chars("#>-"));
310
311 let chunk_size = 1_048_576; let mut file_pointer = 0;
313
314 let mut reader = std::io::BufReader::with_capacity(chunk_size, &mut file);
315
316 let client = reqwest::blocking::Client::builder()
317 .default_headers(reqwest::header::HeaderMap::default())
318 .build()
319 .unwrap();
320
321 while let Some(chunk) = reader.fill_buf().ok().map(|s| s.to_vec()) {
322 let n = chunk.len();
323
324 if chunk.is_empty() {
325 break;
326 }
327
328 let start = file_pointer;
329 let end = file_pointer + chunk.len().saturating_sub(1);
330 let content_range = format!("bytes {start}-{end}/{total}");
331
332 let res = client
333 .put(&session_uri)
334 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
335 .header(reqwest::header::CONTENT_LENGTH, format!("{}", chunk.len()))
336 .header("Content-Range".to_string(), content_range)
337 .body(chunk.to_vec());
338
339 pb.set_position(file_pointer as u64);
340
341 res.send()
342 .map(|response| response.error_for_status())
343 .map_err(|e| {
344 anyhow!(
345 "cannot send request to {session_uri} (chunk {}..{}): {e}",
346 file_pointer,
347 file_pointer + chunk_size
348 )
349 })??;
350
351 if n < chunk_size {
352 break;
353 }
354
355 reader.consume(n);
356 file_pointer += n;
357 }
358
359 pb.finish_and_clear();
360
361 if !quiet {
362 println!("{} {}Publishing...", style("[2/2]").bold().dim(), PACKAGE,);
363 }
364
365 let q =
366 PublishPackageMutationChunked::build_query(publish_package_mutation_chunked::Variables {
367 name: package.name.to_string(),
368 version: package.version.to_string(),
369 description: package.description.clone(),
370 manifest: manifest_string.to_string(),
371 license: package.license.clone(),
372 license_file: license_file.to_owned(),
373 readme: readme.to_owned(),
374 repository: package.repository.clone(),
375 homepage: package.homepage.clone(),
376 file_name: Some(archive_name.to_string()),
377 signature: maybe_signature_data,
378 signed_url: Some(signed_url),
379 });
380
381 let _response: publish_package_mutation_chunked::ResponseData =
382 crate::graphql::execute_query(&q)?;
383
384 println!(
385 "Successfully published package `{}@{}`",
386 package.name, package.version
387 );
388
389 Ok(())
390}
391
392fn on_error(e: anyhow::Error) -> anyhow::Error {
393 #[cfg(feature = "telemetry")]
394 sentry::integrations::anyhow::capture_anyhow(&e);
395
396 e
397}
398
399#[derive(Debug, Error)]
400enum PublishError {
401 #[error("Cannot publish without a module.")]
402 NoModule,
403 #[error("Unable to publish the \"{module}\" module because \"{}\" is not a file", path.display())]
404 SourceMustBeFile { module: String, path: PathBuf },
405 #[error("Unable to load the bindings for \"{module}\" because \"{}\" doesn't exist", path.display())]
406 MissingBindings { module: String, path: PathBuf },
407 #[error("Error building package when parsing module \"{0}\".")]
408 ErrorBuildingPackage(String),
409 #[error(
410 "Path \"{0}\", specified in the manifest as part of the package file system does not exist.",
411 )]
412 MissingManifestFsPath(String),
413 #[error("When processing the package filesystem, found path \"{0}\" which is not a directory")]
414 PackageFileSystemEntryMustBeDirectory(String),
415}
416
417#[derive(Debug, Clone)]
418pub enum SignArchiveResult {
419 Ok {
420 public_key_id: String,
421 signature: String,
422 },
423 NoKeyRegistered,
424}
425
426pub fn sign_compressed_archive(
429 compressed_archive: &mut fs::File,
430) -> anyhow::Result<SignArchiveResult> {
431 let key_db = database::open_db()?;
432 let personal_key = if let Ok(v) = keys::get_active_personal_key(&key_db) {
433 v
434 } else {
435 return Ok(SignArchiveResult::NoKeyRegistered);
436 };
437 let password = rpassword::prompt_password(format!(
438 "Please enter your password for the key pair {}:",
439 &personal_key.public_key_id
440 ))
441 .ok();
442 let private_key = if let Some(priv_key_location) = personal_key.private_key_location {
443 match minisign::SecretKey::from_file(&priv_key_location, password) {
444 Ok(priv_key_data) => priv_key_data,
445 Err(e) => {
446 error!(
447 "Could not read private key from location {}: {}",
448 priv_key_location, e
449 );
450 return Err(e.into());
451 }
452 }
453 } else {
454 warn!("Active key does not have a private key location registered with it!");
456 return Err(anyhow!("Cannot sign package, no private key"));
457 };
458 Ok(SignArchiveResult::Ok {
459 public_key_id: personal_key.public_key_id,
460 signature: (minisign::sign(
461 Some(&minisign::PublicKey::from_base64(
462 &personal_key.public_key_value,
463 )?),
464 &private_key,
465 compressed_archive,
466 false,
467 None,
468 None,
469 )?
470 .to_string()),
471 })
472}