Skip to main content

wapm_cli/commands/
publish.rs

1//! The publish command uploads the package specified in the Manifest (`wapm.toml`)
2//! to the wapm registry.
3use 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    /// Run the publish logic without sending anything to the registry server
32    #[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            // TODO: Maybe do something here
96        }
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            // TODO: Maybe do something here
103        }
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    // bundle the package filesystem
136    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                                                        // TODO:
157                                                        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        // dry run: publish is done here
178
179        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            // TODO: uncomment this when we actually want users to start using it
233            //warn!("Publishing package without a verifying signature. Consider registering a key pair with wapm");
234            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    // archive_path
298    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; // 1MB - 315s / 100MB
312    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
426/// Takes the package archive as a File and attempts to sign it using the active key
427/// returns the public key id used to sign it and the signature string itself
428pub 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        // TODO: add more info about why this might have happened and what the user can do about it
455        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}