wash_cli/
par.rs

1use std::fs::File;
2use std::io::Read;
3use std::{collections::HashMap, path::PathBuf};
4
5use anyhow::{anyhow, bail, Context, Result};
6use clap::{Parser, Subcommand};
7use nkeys::KeyPairType;
8use provider_archive::ProviderArchive;
9use serde_json::json;
10use tracing::warn;
11use wash_lib::cli::par::{
12    convert_error, create_provider_archive, detect_arch, insert_provider_binary,
13};
14use wash_lib::cli::{extract_keypair, inspect, par, CommandOutput, OutputKind};
15
16const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
17
18#[derive(Debug, Clone, Subcommand)]
19pub enum ParCliCommand {
20    /// Build a provider archive file
21    #[clap(name = "create")]
22    Create(CreateCommand),
23    /// Inspect a provider archive file
24    #[clap(name = "inspect")]
25    Inspect(InspectCommand),
26    /// Insert a provider into a provider archive file
27    #[clap(name = "insert")]
28    Insert(InsertCommand),
29}
30
31#[derive(Parser, Debug, Clone)]
32pub struct CreateCommand {
33    /// Vendor string to help identify the publisher of the provider (e.g. Redis, Cassandra, wasmcloud, etc). Not unique.
34    #[clap(short = 'v', long = "vendor")]
35    vendor: String,
36
37    /// Monotonically increasing revision number
38    #[clap(short = 'r', long = "revision")]
39    revision: Option<i32>,
40
41    /// Human friendly version string
42    #[clap(long = "version")]
43    version: Option<String>,
44
45    /// Optional path to a JSON schema describing the link definition specification for this provider.
46    #[clap(
47        short = 'j',
48        long = "schema",
49        env = "WASH_JSON_SCHEMA",
50        hide_env_values = true
51    )]
52    schema: Option<PathBuf>,
53
54    /// Location of key files for signing. Defaults to $WASH_KEYS ($HOME/.wash/keys)
55    #[clap(
56        short = 'd',
57        long = "directory",
58        env = "WASH_KEYS",
59        hide_env_values = true
60    )]
61    directory: Option<PathBuf>,
62
63    /// Path to issuer seed key (account). If this flag is not provided, the will be sourced from $WASH_KEYS ($HOME/.wash/keys) or generated for you if it cannot be found.
64    #[clap(
65        short = 'i',
66        long = "issuer",
67        env = "WASH_ISSUER_KEY",
68        hide_env_values = true
69    )]
70    issuer: Option<String>,
71
72    /// Path to subject seed key (service). If this flag is not provided, the will be sourced from $WASH_KEYS ($HOME/.wash/keys) or generated for you if it cannot be found.
73    #[clap(
74        short = 's',
75        long = "subject",
76        env = "WASH_SUBJECT_KEY",
77        hide_env_values = true
78    )]
79    subject: Option<String>,
80
81    /// Name of the capability provider
82    #[clap(short = 'n', long = "name")]
83    name: String,
84
85    /// Architecture of provider binary in format ARCH-OS (e.g. x86_64-linux)
86    #[clap(short = 'a', long = "arch", default_value_t = detect_arch())]
87    arch: String,
88
89    /// Path to provider binary for populating the archive
90    #[clap(short = 'b', long = "binary")]
91    binary: String,
92
93    /// File output destination path
94    #[clap(long = "destination")]
95    destination: Option<String>,
96
97    /// Include a compressed provider archive
98    #[clap(long = "compress")]
99    compress: bool,
100
101    /// Disables autogeneration of signing keys
102    #[clap(long = "disable-keygen")]
103    disable_keygen: bool,
104
105    /// Location of project directory containing WIT
106    #[clap(long = "wit-directory", env = "WIT_DIR")]
107    wit_dir: Option<PathBuf>,
108}
109
110#[derive(Parser, Debug, Clone)]
111pub struct InspectCommand {
112    /// Path to provider archive or OCI URL of provider archive
113    #[clap(name = "archive")]
114    archive: String,
115
116    /// Digest to verify artifact against (if OCI URL is provided for `<archive>`)
117    #[clap(short = 'd', long = "digest")]
118    digest: Option<String>,
119
120    /// Allow latest artifact tags (if OCI URL is provided for `<archive>`)
121    #[clap(long = "allow-latest")]
122    allow_latest: bool,
123
124    /// OCI username, if omitted anonymous authentication will be used
125    #[clap(
126        short = 'u',
127        long = "user",
128        env = "WASH_REG_USER",
129        hide_env_values = true
130    )]
131    user: Option<String>,
132
133    /// OCI password, if omitted anonymous authentication will be used
134    #[clap(
135        short = 'p',
136        long = "password",
137        env = "WASH_REG_PASSWORD",
138        hide_env_values = true
139    )]
140    password: Option<String>,
141
142    /// Allow insecure (HTTP) registry connections
143    #[clap(long = "insecure")]
144    insecure: bool,
145
146    /// Skip checking OCI registry's certificate for validity
147    #[clap(long = "insecure-skip-tls-verify")]
148    pub insecure_skip_tls_verify: bool,
149
150    /// skip the local OCI cache
151    #[clap(long = "no-cache")]
152    no_cache: bool,
153}
154
155#[derive(Parser, Debug, Clone)]
156pub struct InsertCommand {
157    /// Path to provider archive
158    #[clap(name = "archive")]
159    archive: String,
160
161    /// Architecture of binary in format ARCH-OS (e.g. x86_64-linux)
162    #[clap(short = 'a', long = "arch", default_value_t = detect_arch())]
163    arch: String,
164
165    /// Path to provider binary to insert into archive
166    #[clap(short = 'b', long = "binary")]
167    binary: String,
168
169    /// Location of key files for signing. Defaults to $WASH_KEYS ($HOME/.wash/keys)
170    #[clap(
171        short = 'd',
172        long = "directory",
173        env = "WASH_KEYS",
174        hide_env_values = true
175    )]
176    directory: Option<PathBuf>,
177
178    /// Path to issuer seed key (account). If this flag is not provided, the will be sourced from $WASH_KEYS ($HOME/.wash/keys) or generated for you if it cannot be found.
179    #[clap(
180        short = 'i',
181        long = "issuer",
182        env = "WASH_ISSUER_KEY",
183        hide_env_values = true
184    )]
185    issuer: Option<String>,
186
187    /// Path to subject seed key (service). If this flag is not provided, the will be sourced from $WASH_KEYS ($HOME/.wash/keys) or generated for you if it cannot be found.
188    #[clap(
189        short = 's',
190        long = "subject",
191        env = "WASH_SUBJECT_KEY",
192        hide_env_values = true
193    )]
194    subject: Option<String>,
195
196    /// Disables autogeneration of signing keys
197    #[clap(long = "disable-keygen")]
198    disable_keygen: bool,
199}
200
201impl From<InspectCommand> for inspect::InspectCliCommand {
202    fn from(cmd: InspectCommand) -> Self {
203        inspect::InspectCliCommand {
204            target: cmd.archive,
205            jwt_only: false,
206            wit: false,
207            digest: cmd.digest,
208            allow_latest: cmd.allow_latest,
209            user: cmd.user,
210            password: cmd.password,
211            insecure: cmd.insecure,
212            insecure_skip_tls_verify: cmd.insecure_skip_tls_verify,
213            no_cache: cmd.no_cache,
214        }
215    }
216}
217
218impl From<CreateCommand> for par::ParCreateArgs {
219    fn from(cmd: CreateCommand) -> Self {
220        par::ParCreateArgs {
221            vendor: cmd.vendor,
222            revision: cmd.revision,
223            version: cmd.version,
224            schema: cmd.schema,
225            name: cmd.name,
226            arch: cmd.arch,
227        }
228    }
229}
230
231pub async fn handle_command(
232    command: ParCliCommand,
233    output_kind: OutputKind,
234) -> Result<CommandOutput> {
235    match command {
236        ParCliCommand::Create(cmd) => handle_create(cmd, output_kind).await,
237        ParCliCommand::Inspect(cmd) => {
238            warn!("par inspect will be deprecated in future versions. Use inspect instead.");
239            inspect::handle_command(cmd, output_kind).await
240        }
241        ParCliCommand::Insert(cmd) => handle_insert(cmd, output_kind).await,
242    }
243}
244
245/// Creates a provider archive using an initial architecture target, provider, and signing keys
246pub async fn handle_create(cmd: CreateCommand, output_kind: OutputKind) -> Result<CommandOutput> {
247    let mut f = File::open(cmd.binary.clone())
248        .with_context(|| format!("failed to load binary [{}]", &cmd.binary))?;
249    let mut lib = Vec::new();
250    f.read_to_end(&mut lib)?;
251
252    let issuer = extract_keypair(
253        cmd.issuer.as_deref(),
254        Some(&cmd.binary),
255        cmd.directory.clone(),
256        KeyPairType::Account,
257        cmd.disable_keygen,
258        output_kind,
259    )?;
260    let subject = extract_keypair(
261        cmd.subject.as_deref(),
262        Some(&cmd.binary),
263        cmd.directory.clone(),
264        KeyPairType::Service,
265        cmd.disable_keygen,
266        output_kind,
267    )?;
268
269    let extension = if cmd.compress { ".par.gz" } else { ".par" };
270    let outfile = match cmd.destination.clone() {
271        Some(path) => path,
272        None => format!(
273            "{}{}",
274            PathBuf::from(&cmd.binary)
275                .file_stem()
276                .unwrap()
277                .to_str()
278                .unwrap(),
279            extension
280        ),
281    };
282
283    let wit_interface_bytes = match cmd.wit_dir.as_ref() {
284        Some(dir) => {
285            let mut resolve = wit_parser::Resolve::default();
286            let (package_id, _paths) = resolve
287                .push_dir(dir)
288                .with_context(|| format!("failed to add WIT directory @ [{}]", dir.display()))?;
289
290            let encoded = wit_component::encode(&resolve, package_id)
291                .context("Failed to encode WIT package")?;
292
293            Some(encoded)
294        }
295        None => None,
296    };
297
298    let compress = cmd.compress;
299    let mut par = create_provider_archive(cmd.into(), &lib, wit_interface_bytes.as_deref())
300        .context("failed to create provider archive with built provider")?;
301    par.write(&outfile, &issuer, &subject, compress)
302        .await
303        .map_err(|e| anyhow!("{e}"))
304        .with_context(|| {
305            format!(
306                "Error writing PAR. Please ensure directory {:?} exists",
307                PathBuf::from(&outfile).parent().unwrap(),
308            )
309        })?;
310
311    let mut map = HashMap::new();
312    map.insert("file".to_string(), json!(outfile));
313    Ok(CommandOutput::new(
314        format!("Successfully created archive {outfile}"),
315        map,
316    ))
317}
318
319/// Loads a provider archive and attempts to insert an additional provider into it
320pub async fn handle_insert(cmd: InsertCommand, output_kind: OutputKind) -> Result<CommandOutput> {
321    let mut buf = Vec::new();
322    let mut f = File::open(cmd.archive.clone())
323        .with_context(|| format!("failed to load provider archive [{}]", &cmd.archive))?;
324    f.read_to_end(&mut buf)?;
325
326    let mut f = File::open(cmd.binary.clone())
327        .with_context(|| format!("failed to load binary [{}]", &cmd.archive))?;
328    let mut lib = Vec::new();
329    f.read_to_end(&mut lib)?;
330
331    let issuer = extract_keypair(
332        cmd.issuer.as_deref(),
333        Some(&cmd.binary),
334        cmd.directory.clone(),
335        KeyPairType::Account,
336        cmd.disable_keygen,
337        output_kind,
338    )?;
339    let subject = extract_keypair(
340        cmd.subject.as_deref(),
341        Some(&cmd.binary),
342        cmd.directory.clone(),
343        KeyPairType::Service,
344        cmd.disable_keygen,
345        output_kind,
346    )?;
347
348    let mut par = ProviderArchive::try_load(&buf)
349        .await
350        .map_err(convert_error)?;
351
352    par = insert_provider_binary(cmd.arch, &lib, par).await?;
353    par.write(&cmd.archive, &issuer, &subject, is_compressed(&buf)?)
354        .await
355        .map_err(convert_error)?;
356
357    let mut map = HashMap::new();
358    map.insert("file".to_string(), json!(cmd.archive));
359    Ok(CommandOutput::new(
360        format!(
361            "Successfully inserted {} into archive {}",
362            cmd.binary, cmd.archive
363        ),
364        map,
365    ))
366}
367
368/// Inspects the byte slice for a GZIP header, and returns true if the file is compressed
369fn is_compressed(input: &[u8]) -> Result<bool> {
370    if input.len() < 2 {
371        bail!("Not enough bytes to be a valid PAR file");
372    }
373    Ok(input[0..2] == GZIP_MAGIC)
374}
375
376#[cfg(test)]
377mod test {
378    use super::*;
379
380    #[derive(Parser, Debug)]
381    struct Cmd {
382        #[clap(subcommand)]
383        par: ParCliCommand,
384    }
385
386    // Uses all flags and options of the `par create` command
387    // to ensure API does not change between versions
388    #[test]
389    fn test_par_create_comprehensive() {
390        const ISSUER: &str = "SAAJLQZDZO57THPTIIEELEY7FJYOJZQWQD7FF4J67TUYTSCOXTF7R4Y3VY";
391        const SUBJECT: &str = "SVAH7IN6QE6XODCGIIWZQDZ5LNSSS4FNEO6SNHZSSASW4BBBKSZ6KWTKWY";
392        let create_long: Cmd = clap::Parser::try_parse_from([
393            "par",
394            "create",
395            "--arch",
396            "x86_64-testrunner",
397            "--binary",
398            "./testrunner.so",
399            "--name",
400            "CreateTest",
401            "--vendor",
402            "TestRunner",
403            "--destination",
404            "./test.par.gz",
405            "--revision",
406            "1",
407            "--version",
408            "1.11.111",
409            "--directory",
410            "./tests/fixtures",
411            "--issuer",
412            ISSUER,
413            "--subject",
414            SUBJECT,
415            "--disable-keygen",
416            "--compress",
417            "--wit-directory",
418            "./wit",
419        ])
420        .unwrap();
421        match create_long.par {
422            ParCliCommand::Create(CreateCommand {
423                vendor,
424                revision,
425                version,
426                schema,
427                directory,
428                issuer,
429                subject,
430                name,
431                arch,
432                binary,
433                destination,
434                compress,
435                disable_keygen,
436                wit_dir,
437            }) => {
438                assert_eq!(arch, "x86_64-testrunner");
439                assert_eq!(binary, "./testrunner.so");
440                assert_eq!(directory.unwrap(), PathBuf::from("./tests/fixtures"));
441                assert_eq!(issuer.unwrap(), ISSUER);
442                assert_eq!(subject.unwrap(), SUBJECT);
443                assert_eq!(name, "CreateTest");
444                assert_eq!(vendor, "TestRunner");
445                assert_eq!(destination.unwrap(), "./test.par.gz");
446                assert_eq!(revision.unwrap(), 1);
447                assert_eq!(version.unwrap(), "1.11.111");
448                assert_eq!(schema, None);
449                assert!(disable_keygen);
450                assert!(compress);
451                assert_eq!(wit_dir.unwrap(), PathBuf::from("./wit"));
452            }
453            cmd => panic!("par insert constructed incorrect command {cmd:?}"),
454        }
455        let create_short: Cmd = clap::Parser::try_parse_from([
456            "par",
457            "create",
458            "-a",
459            "x86_64-testrunner",
460            "-b",
461            "./testrunner.so",
462            "-n",
463            "CreateTest",
464            "-v",
465            "TestRunner",
466            "--destination",
467            "./test.par.gz",
468            "-r",
469            "1",
470            "--version",
471            "1.11.111",
472            "-d",
473            "./tests/fixtures",
474            "-i",
475            ISSUER,
476            "-s",
477            SUBJECT,
478            "--wit-directory",
479            "./wit",
480        ])
481        .unwrap();
482        match create_short.par {
483            ParCliCommand::Create(CreateCommand {
484                vendor,
485                revision,
486                version,
487                schema,
488                directory,
489                issuer,
490                subject,
491                name,
492                arch,
493                binary,
494                destination,
495                compress,
496                disable_keygen,
497                wit_dir,
498            }) => {
499                assert_eq!(arch, "x86_64-testrunner");
500                assert_eq!(binary, "./testrunner.so");
501                assert_eq!(directory.unwrap(), PathBuf::from("./tests/fixtures"));
502                assert_eq!(issuer.unwrap(), ISSUER);
503                assert_eq!(subject.unwrap(), SUBJECT);
504                assert_eq!(name, "CreateTest");
505                assert_eq!(vendor, "TestRunner");
506                assert_eq!(destination.unwrap(), "./test.par.gz");
507                assert_eq!(revision.unwrap(), 1);
508                assert_eq!(version.unwrap(), "1.11.111");
509                assert_eq!(schema, None);
510                assert!(!disable_keygen);
511                assert!(!compress);
512                assert_eq!(wit_dir.unwrap(), PathBuf::from("./wit"));
513            }
514            cmd => panic!("par insert constructed incorrect command {cmd:?}"),
515        }
516    }
517
518    // Uses all flags and options of the `par insert` command
519    // to ensure API does not change between versions
520    #[test]
521    fn test_par_insert_comprehensive() {
522        const ISSUER: &str = "SAAJLQZDZO57THPTQLEELEY7FJYOJZQWQD7FF4J67TUYTSCOXTF7R4Y3VY";
523        const SUBJECT: &str = "SVAH7IN6QE6XODCGQAWZQDZ5LNSSS4FNEO6SNHZSSASW4BBBKSZ6KWTKWY";
524        let insert_short: Cmd = clap::Parser::try_parse_from([
525            "par",
526            "insert",
527            "libtest.par.gz",
528            "-a",
529            "x86_64-testrunner",
530            "-b",
531            "./testrunner.so",
532            "-d",
533            "./tests/fixtures",
534            "-i",
535            ISSUER,
536            "-s",
537            SUBJECT,
538            "--disable-keygen",
539        ])
540        .unwrap();
541        match insert_short.par {
542            ParCliCommand::Insert(InsertCommand {
543                archive,
544                arch,
545                binary,
546                directory,
547                issuer,
548                subject,
549                disable_keygen,
550            }) => {
551                assert_eq!(archive, "libtest.par.gz");
552                assert_eq!(arch, "x86_64-testrunner");
553                assert_eq!(binary, "./testrunner.so");
554                assert_eq!(directory.unwrap(), PathBuf::from("./tests/fixtures"));
555                assert_eq!(issuer.unwrap(), ISSUER);
556                assert_eq!(subject.unwrap(), SUBJECT);
557                assert!(disable_keygen);
558            }
559            cmd => panic!("par insert constructed incorrect command {cmd:?}"),
560        }
561        let insert_long: Cmd = clap::Parser::try_parse_from([
562            "par",
563            "insert",
564            "libtest.par.gz",
565            "--arch",
566            "x86_64-testrunner",
567            "--binary",
568            "./testrunner.so",
569            "--directory",
570            "./tests/fixtures",
571            "--issuer",
572            ISSUER,
573            "--subject",
574            SUBJECT,
575        ])
576        .unwrap();
577        match insert_long.par {
578            ParCliCommand::Insert(InsertCommand {
579                archive,
580                arch,
581                binary,
582                directory,
583                issuer,
584                subject,
585                disable_keygen,
586            }) => {
587                assert_eq!(archive, "libtest.par.gz");
588                assert_eq!(arch, "x86_64-testrunner");
589                assert_eq!(binary, "./testrunner.so");
590                assert_eq!(directory.unwrap(), PathBuf::from("./tests/fixtures"));
591                assert_eq!(issuer.unwrap(), ISSUER);
592                assert_eq!(subject.unwrap(), SUBJECT);
593                assert!(!disable_keygen);
594            }
595            cmd => panic!("par insert constructed incorrect command {cmd:?}"),
596        }
597    }
598
599    // Uses all flags and options of the `par inspect` command
600    // to ensure API does not change between versions
601    #[test]
602    fn test_par_inspect_comprehensive() {
603        const LOCAL: &str = "./coolthing.par.gz";
604        const REMOTE: &str = "wasmcloud.azurecr.io/coolthing.par.gz";
605
606        let inspect_long: Cmd = clap::Parser::try_parse_from([
607            "par",
608            "inspect",
609            LOCAL,
610            "--digest",
611            "sha256:blah",
612            "--password",
613            "secret",
614            "--user",
615            "name",
616            "--no-cache",
617        ])
618        .unwrap();
619        match inspect_long.par {
620            ParCliCommand::Inspect(InspectCommand {
621                archive,
622                digest,
623                allow_latest,
624                user,
625                password,
626                insecure,
627                insecure_skip_tls_verify,
628                no_cache,
629            }) => {
630                assert_eq!(archive, LOCAL);
631                assert_eq!(digest.unwrap(), "sha256:blah");
632                assert!(!allow_latest);
633                assert!(!insecure);
634                assert!(!insecure_skip_tls_verify);
635                assert_eq!(user.unwrap(), "name");
636                assert_eq!(password.unwrap(), "secret");
637                assert!(no_cache);
638            }
639            cmd => panic!("par inspect constructed incorrect command {cmd:?}"),
640        }
641        let inspect_short: Cmd = clap::Parser::try_parse_from([
642            "par",
643            "inspect",
644            REMOTE,
645            "-d",
646            "sha256:blah",
647            "-p",
648            "secret",
649            "-u",
650            "name",
651            "--allow-latest",
652            "--insecure",
653            "--no-cache",
654        ])
655        .unwrap();
656        match inspect_short.par {
657            ParCliCommand::Inspect(InspectCommand {
658                archive,
659                digest,
660                allow_latest,
661                user,
662                password,
663                insecure,
664                insecure_skip_tls_verify,
665                no_cache,
666            }) => {
667                assert_eq!(archive, REMOTE);
668                assert_eq!(digest.unwrap(), "sha256:blah");
669                assert!(allow_latest);
670                assert!(insecure);
671                assert!(!insecure_skip_tls_verify);
672                assert_eq!(user.unwrap(), "name");
673                assert_eq!(password.unwrap(), "secret");
674                assert!(no_cache);
675            }
676            cmd => panic!("par inspect constructed incorrect command {cmd:?}"),
677        }
678    }
679}