diff --git a/src/cli.rs b/src/cli.rs index 6338cac..a214375 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,16 +1,12 @@ use crate::client::{Client, HTTPClient}; use crate::validation; -use clap::Args; -use clap::{CommandFactory, Parser, Subcommand, ValueHint}; -use clap_complete::generate; -use clap_complete::Shell; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueHint}; +use clap_complete::{generate, Shell}; use error_stack::{Context, Report, Result, ResultExt}; use serde_json::Value; use std::collections::BTreeMap; -use std::env; -use std::fmt; -use std::io; use std::path::PathBuf; +use std::{env, fmt, fs, io}; use uuid::Uuid; #[derive(Parser, Debug)] @@ -50,6 +46,9 @@ enum Commands { #[command(subcommand)] Scan(Scan), + #[command(subcommand)] + Blob(Blob), + /// Shell completion commands #[command(arg_required_else_help = true)] Completion(Completion), @@ -67,6 +66,7 @@ impl Commands { Commands::Completion(_) => unreachable!(), Commands::Job(job) => job.run(client)?, Commands::Scan(scan) => scan.run(client)?, + Commands::Blob(blob) => blob.run(client)?, } Ok(()) @@ -204,7 +204,7 @@ impl Job { .change_context(CliError) .attach_printable("Failed to download file")?; - let mut fwriter = std::fs::File::create(output) + let mut fwriter = fs::File::create(output) .change_context(CliError) .attach_printable("Failed to create file")?; @@ -330,6 +330,78 @@ impl Scan { } } +#[derive(Subcommand, Debug, Clone)] +enum Blob { + Download { + #[clap(short, long, required = true)] + path: String, + #[clap(short, long, env = "BOUNTYHUB_OUTPUT")] + #[arg(value_hint = ValueHint::DirPath)] + output: Option, + }, + Upload { + /// src is the source file on the local filesystem + #[clap(short, long, required = true)] + #[arg(value_hint = ValueHint::DirPath)] + src: String, + + /// dst is the destination path on bountyhub.org blobs + #[clap(long, required = true)] + dst: String, + }, +} + +impl Blob { + fn run(self, client: C) -> Result<(), CliError> + where + C: Client, + { + match self { + Blob::Download { path, output } => { + let output = match output { + Some(output) => { + let output = PathBuf::from(output); + if output.is_dir() { + output.join(&path) + } else { + output + } + } + None => env::current_dir() + .change_context(CliError) + .attach_printable("Failed to get current directory")? + .join(&path), + }; + + let mut freader = client + .download_blob_file(&path) + .change_context(CliError) + .attach_printable("Failed to download file")?; + + let mut fwriter = fs::File::create(output) + .change_context(CliError) + .attach_printable("Failed to create file")?; + + std::io::copy(&mut *freader, &mut fwriter) + .change_context(CliError) + .attach_printable("failed to write file")?; + } + Blob::Upload { src, dst } => { + let freader = fs::File::open(&src) + .change_context(CliError) + .attach_printable(format!("failed to open file '{src}'"))?; + + client + .upload_blob_file(freader, dst.as_str()) + .change_context(CliError) + .attach_printable("failed to call upload blob file")?; + } + } + + Ok(()) + } +} + #[cfg(test)] mod job_tests { use super::*; @@ -479,6 +551,23 @@ mod job_tests { assert_eq!(result.0, "k"); assert_eq!(result.1, "v=a"); } + + #[test] + fn test_download_blob_file() { + let cmd = Blob::Download { + path: "file.txt".to_string(), + output: None, + }; + let mut client = MockClient::new(); + client + .expect_download_blob_file() + .with(function(|v| v == "file.txt")) + .times(1) + .returning(|_| Err(Report::new(ClientError))); + + let result = cmd.run(client); + assert!(result.is_err(), "expected error, got ok"); + } } #[derive(Args, Debug)] diff --git a/src/client.rs b/src/client.rs index f022db5..be3b012 100644 --- a/src/client.rs +++ b/src/client.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use std::fmt; +use std::fs::File; use std::io::Read; use std::time::Duration; use ureq::Agent; @@ -17,6 +18,12 @@ pub struct DispatchScanRequest { pub inputs: Option>, } +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UploadBlobFileRequest { + pub path: String, +} + #[cfg_attr(test, automock)] pub trait Client { fn download_job_result_file( @@ -43,6 +50,13 @@ pub trait Client { scan_name: String, inputs: Option>, ) -> Result<(), ClientError>; + + fn download_blob_file( + &self, + path: &str, + ) -> Result, ClientError>; + + fn upload_blob_file(&self, file: File, dst: &str) -> Result<(), ClientError>; } pub struct HTTPClient { @@ -98,7 +112,7 @@ impl Client for HTTPClient { job_id: Uuid, ) -> Result, ClientError> { let url = format!("{0}/api/v0/projects/{project_id}/workflows/{workflow_id}/revisions/{revision_id}/jobs/{job_id}/result", self.bountyhub_domain); - let FileResult { url } = self + let UrlResponse { url } = self .bountyhub_agent .get(url.as_str()) .set("Authorization", self.authorization.as_str()) @@ -166,6 +180,56 @@ impl Client for HTTPClient { Err(e) => Err(Report::new(ClientError).attach_printable(e.to_string())), } } + + fn download_blob_file( + &self, + path: &str, + ) -> Result, ClientError> { + let url = format!("{0}/api/v0/blobs/{path}", self.bountyhub_domain); + let UrlResponse { url } = self + .bountyhub_agent + .get(url.as_str()) + .set("Authorization", self.authorization.as_str()) + .call() + .change_context(ClientError) + .attach_printable("Failed to request download")? + .into_json() + .change_context(ClientError) + .attach_printable("Failed to parse response")?; + + let res = self + .file_agent + .get(url.as_str()) + .call() + .change_context(ClientError) + .attach_printable("Failed to download file")?; + + Ok(res.into_reader()) + } + + fn upload_blob_file(&self, file: File, dst: &str) -> Result<(), ClientError> { + let url = format!("{0}/api/v0/blobs/files", self.bountyhub_domain); + let UrlResponse { url } = self + .bountyhub_agent + .post(url.as_str()) + .set("Authorization", self.authorization.as_str()) + .send_json(UploadBlobFileRequest { + path: dst.to_string(), + }) + .change_context(ClientError) + .attach_printable("failed to create upload link")? + .into_json() + .change_context(ClientError) + .attach_printable("failed to parse response")?; + + self.file_agent + .put(&url) + .send(file) + .change_context(ClientError) + .attach_printable("failed to send file")?; + + Ok(()) + } } #[derive(Debug)] @@ -180,6 +244,6 @@ impl fmt::Display for ClientError { impl Context for ClientError {} #[derive(Deserialize, Debug)] -struct FileResult { +struct UrlResponse { url: String, }