From a791be3aba39580e39caf7c5d579f97166809623 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 12 Jan 2026 19:51:28 +0100 Subject: [PATCH 01/12] feat: implement sbom cli Signed-off-by: Ruben Romero Montes Assisted by: Cursor --- .gitignore | 2 +- Cargo.toml | 3 +- LICENSE | 2 +- README.md | 2 +- src/api/client.rs | 97 +++++++++++ src/api/mod.rs | 4 + src/api/sbom.rs | 397 +++++++++++++++++++++++++++++++++++++++++++ src/auth.rs | 69 ++++++++ src/cli.rs | 17 ++ src/commands/mod.rs | 23 +++ src/commands/sbom.rs | 360 +++++++++++++++++++++++++++++++++++++++ src/config.rs | 36 ++++ src/main.rs | 56 ++++++ 13 files changed, 1064 insertions(+), 4 deletions(-) create mode 100644 src/api/client.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/sbom.rs create mode 100644 src/auth.rs create mode 100644 src/cli.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/sbom.rs create mode 100644 src/config.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index 1c817d349..0c74bc352 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ /dump.sql .ai_history.txt /vendor/ -/vendor.toml +/vendor.toml \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 0a1b3ae48..1599c7100 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ libz-sys = "*" log = "0.4.19" mime = "0.3.17" moka = "0.12.10" +mockall = "0.12" native-tls = "0.2" num-traits = "0.2" oci-client = "0.16.0" @@ -211,4 +212,4 @@ csaf = { git = "https://github.com/scm-rs/csaf-rs", branch = "main" } #osv = { git = "https://github.com/ctron/osv", branch = "feature/drop_deps_1" } # required due to https://github.com/doubleopen-project/spdx-rs/pull/35 -spdx-rs = { git = "https://github.com/ctron/spdx-rs", branch = "feature/add_alias_2" } +spdx-rs = { git = "https://github.com/ctron/spdx-rs", branch = "feature/add_alias_2" } \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9e9..f49a4e16e 100644 --- a/LICENSE +++ b/LICENSE @@ -198,4 +198,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 3601c9157..778f5b69b 100644 --- a/README.md +++ b/README.md @@ -279,4 +279,4 @@ CPE (Product?) and/or pURLs described by the SBOM ## Related links * [Trustify scale test results](https://guacsec.github.io/trustify-scale-test-runs/) -* [Trustify benchmark results](https://guacsec.github.io/trustify/dev/bench/) +* [Trustify benchmark results](https://guacsec.github.io/trustify/dev/bench/) \ No newline at end of file diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 000000000..e0910bd10 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,97 @@ +use reqwest::{Client, RequestBuilder}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("Request failed: {0}")] + RequestError(#[from] reqwest::Error), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Unauthorized: Please check your authentication credentials")] + Unauthorized, + + #[error("Server error: {0}")] + ServerError(String), +} + +/// API client for Trustify +#[derive(Clone)] +pub struct ApiClient { + client: Client, + base_url: String, + token: Option, +} + +impl ApiClient { + pub fn new(base_url: &str, token: Option) -> Self { + // Normalize base URL (remove trailing slash) + let base_url = base_url.trim_end_matches('/').to_string(); + + Self { + client: Client::new(), + base_url, + token, + } + } + + /// Build the full API URL + pub fn url(&self, path: &str) -> String { + format!("{}/api{}", self.base_url, path) + } + + /// Add authorization header if token is present + fn authorize(&self, request: RequestBuilder) -> RequestBuilder { + match &self.token { + Some(token) => request.bearer_auth(token), + None => request, + } + } + + /// Perform a GET request + pub async fn get(&self, path: &str) -> Result { + let url = self.url(path); + let request = self.client.get(&url); + let response = self.authorize(request).send().await?; + + self.handle_response(response).await + } + + /// Perform a GET request with query parameters + pub async fn get_with_query( + &self, + path: &str, + query: &T, + ) -> Result { + let url = self.url(path); + let request = self.client.get(&url).query(query); + let response = self.authorize(request).send().await?; + + self.handle_response(response).await + } + + /// Perform a DELETE request + pub async fn delete(&self, path: &str) -> Result { + let url = self.url(path); + let request = self.client.delete(&url); + let response = self.authorize(request).send().await?; + + self.handle_response(response).await + } + + async fn handle_response(&self, response: reqwest::Response) -> Result { + let status = response.status(); + + if status.is_success() { + Ok(response.text().await?) + } else if status.as_u16() == 404 { + Err(ApiError::NotFound("Resource not found".to_string())) + } else if status.as_u16() == 401 || status.as_u16() == 403 { + Err(ApiError::Unauthorized) + } else { + let body = response.text().await.unwrap_or_default(); + Err(ApiError::ServerError(format!("HTTP {}: {}", status, body))) + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 000000000..40637dfd2 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod sbom; + +pub use client::ApiClient; diff --git a/src/api/sbom.rs b/src/api/sbom.rs new file mode 100644 index 000000000..12b52ae7a --- /dev/null +++ b/src/api/sbom.rs @@ -0,0 +1,397 @@ +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use futures::future::join_all; +use futures::stream::{self, StreamExt}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::Mutex; + +use super::client::{ApiClient, ApiError}; + +const SBOM_PATH: &str = "/v2/sbom"; + +/// Query parameters for listing SBOMs +#[derive(Default, Serialize)] +pub struct ListParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub q: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, +} + +/// Parameters for find duplicates +pub struct FindDuplicatesParams { + pub batch_size: u32, + pub concurrency: usize, +} + +/// SBOM entry for duplicate detection +#[derive(Debug, Clone)] +struct SbomEntry { + id: String, + document_id: String, + published: Option, +} + +/// Duplicate group output format +#[derive(Debug, Serialize, Deserialize)] +pub struct DuplicateGroup { + pub document_id: String, + pub published: Option, + pub id: String, + pub duplicates: Vec, +} + +/// Get SBOM by ID - returns raw JSON +pub async fn get(client: &ApiClient, id: &str) -> Result { + let path = format!("{}/{}", SBOM_PATH, id); + client.get(&path).await +} + +/// List SBOMs with optional query parameters - returns raw JSON +pub async fn list(client: &ApiClient, params: &ListParams) -> Result { + client.get_with_query(SBOM_PATH, params).await +} + +/// Fetch a single page and extract SBOM entries +async fn fetch_page(client: &ApiClient, batch_size: u32, offset: u32) -> Result, ApiError> { + let list_params = ListParams { + q: None, + limit: Some(batch_size), + offset: Some(offset), + sort: None, + }; + + let response = list(client, &list_params).await?; + let parsed: Value = serde_json::from_str(&response).map_err(|e| { + ApiError::ServerError(format!("Failed to parse response: {}", e)) + })?; + + let items = parsed + .get("items") + .and_then(|v| v.as_array()) + .ok_or_else(|| ApiError::ServerError("No items in response".to_string()))?; + + let entries: Vec = items + .iter() + .filter_map(|item| { + let id = item.get("id").and_then(|v| v.as_str())?.to_string(); + let document_id = item.get("document_id").and_then(|v| v.as_str())?.to_string(); + let published = item.get("published").and_then(|v| v.as_str()).map(|s| s.to_string()); + + if document_id.is_empty() { + None + } else { + Some(SbomEntry { id, document_id, published }) + } + }) + .collect(); + + Ok(entries) +} + +/// Worker that fetches assigned pages sequentially +async fn fetch_worker( + worker_id: usize, + client: ApiClient, + pages: Vec, + batch_size: u32, + progress_bar: ProgressBar, + results: Arc>>, +) { + let mut fetched: u64 = 0; + + for offset in pages { + match fetch_page(&client, batch_size, offset).await { + Ok(entries) => { + fetched += entries.len() as u64; + progress_bar.set_position(fetched); + results.lock().await.extend(entries); + } + Err(e) => { + progress_bar.println(format!("Worker {}: Error at offset {}: {}", worker_id, offset, e)); + } + } + } + + progress_bar.finish_with_message("done"); +} + +/// Find duplicate SBOMs by document_id and save to file +pub async fn find_duplicates( + client: &ApiClient, + params: &FindDuplicatesParams, + output_file: &Option, +) -> Result, ApiError> { + let batch_size = params.batch_size; + let concurrency = params.concurrency; + + // First, get the total count + let first_page = list(client, &ListParams { + q: None, + limit: Some(1), + offset: Some(0), + sort: None, + }).await?; + + let parsed: Value = serde_json::from_str(&first_page).map_err(|e| { + ApiError::ServerError(format!("Failed to parse response: {}", e)) + })?; + + let total = parsed + .get("total") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + if total == 0 { + eprintln!("No SBOMs found"); + return Ok(Vec::new()); + } + + eprintln!("Fetching {} SBOMs with {} workers...\n", total, concurrency); + + // Calculate page offsets + let num_pages = total.div_ceil(batch_size); + let all_offsets: Vec = (0..num_pages).map(|i| i * batch_size).collect(); + + // Distribute pages evenly among workers + let mut worker_pages: Vec> = vec![Vec::new(); concurrency]; + for (i, offset) in all_offsets.into_iter().enumerate() { + worker_pages[i % concurrency].push(offset); + } + + // Calculate how many SBOMs each worker will fetch + let worker_counts: Vec = worker_pages.iter().map(|pages| { + pages.iter().map(|&offset| { + let remaining = total.saturating_sub(offset); + remaining.min(batch_size) as u64 + }).sum() + }).collect(); + + // Set up progress bars + let multi_progress = MultiProgress::new(); + let style = ProgressStyle::default_bar() + .template("{prefix:>12} [{bar:30.cyan/blue}] {pos}/{len} ({percent}%)") + .unwrap() + .progress_chars("█▓░"); + + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + + // Spawn workers + let mut handles = Vec::new(); + for (worker_id, pages) in worker_pages.into_iter().enumerate() { + if pages.is_empty() { + continue; + } + + let worker_total = worker_counts[worker_id]; + let pb = multi_progress.add(ProgressBar::new(worker_total)); + pb.set_style(style.clone()); + pb.set_prefix(format!("Worker {}", worker_id + 1)); + + let client = client.clone(); + let results = Arc::clone(&results); + + handles.push(tokio::spawn(fetch_worker( + worker_id + 1, + client, + pages, + batch_size, + pb, + results, + ))); + } + + // Wait for all workers to complete + join_all(handles).await; + + let all_entries = Arc::try_unwrap(results) + .map_err(|_| ApiError::ServerError("Failed to unwrap entries".to_string()))? + .into_inner(); + + eprintln!("\nProcessing {} SBOMs for duplicates...", all_entries.len()); + + // Group by document_id + let mut groups: HashMap> = HashMap::new(); + for entry in all_entries { + groups + .entry(entry.document_id.clone()) + .or_default() + .push(entry); + } + + // Find duplicates (groups with more than one entry) + let mut duplicate_groups: Vec = Vec::new(); + + for (document_id, mut entries) in groups { + if entries.len() <= 1 { + continue; + } + + // Sort by published date descending (most recent first) + entries.sort_by(|a, b| { + match (&b.published, &a.published) { + (Some(b_pub), Some(a_pub)) => b_pub.cmp(a_pub), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }); + + let most_recent = entries.remove(0); + let duplicates: Vec = entries.into_iter().map(|e| e.id).collect(); + + duplicate_groups.push(DuplicateGroup { + document_id, + published: most_recent.published, + id: most_recent.id, + duplicates, + }); + } + + eprintln!("Found {} document(s) with duplicates", duplicate_groups.len()); + + // Save to file + let output_path = output_file + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("duplicates.json"); + + let json = serde_json::to_string_pretty(&duplicate_groups).map_err(|e| { + ApiError::ServerError(format!("Failed to serialize results: {}", e)) + })?; + + let mut file = File::create(output_path).map_err(|e| { + ApiError::ServerError(format!("Failed to create output file: {}", e)) + })?; + + file.write_all(json.as_bytes()).map_err(|e| { + ApiError::ServerError(format!("Failed to write to file: {}", e)) + })?; + + Ok(duplicate_groups) +} + +/// Delete an SBOM by ID +pub async fn delete(client: &ApiClient, id: &str) -> Result<(), ApiError> { + let path = format!("{}/{}", SBOM_PATH, id); + client.delete(&path).await?; + Ok(()) +} + +/// Result of deleting duplicates +pub struct DeleteDuplicatesResult { + pub deleted: u32, + pub failed: u32, + pub total: u32, +} + +/// Entry to delete with its document_id for logging +#[derive(Clone)] +struct DeleteEntry { + id: String, + document_id: String, +} + +/// Delete duplicates from a file with progress bar +pub async fn delete_duplicates( + client: &ApiClient, + input_file: &str, + concurrency: usize, + dry_run: bool, +) -> Result { + // Check if file exists + let path = Path::new(input_file); + if !path.exists() { + return Err(ApiError::ServerError(format!( + "Input file not found: {}", + input_file + ))); + } + + // Read and parse the file + let file = File::open(path).map_err(|e| { + ApiError::ServerError(format!("Failed to open input file: {}", e)) + })?; + let reader = BufReader::new(file); + + let groups: Vec = serde_json::from_reader(reader).map_err(|e| { + ApiError::ServerError(format!("Failed to parse input file: {}", e)) + })?; + + // Collect all duplicate entries to delete + let entries: Vec = groups + .iter() + .flat_map(|group| { + group.duplicates.iter().map(|id| DeleteEntry { + id: id.clone(), + document_id: group.document_id.clone(), + }) + }) + .collect(); + + let total = entries.len() as u32; + + if dry_run { + for entry in &entries { + eprintln!("[DRY-RUN] Would delete: {} (document_id: {})", entry.id, entry.document_id); + } + return Ok(DeleteDuplicatesResult { + deleted: 0, + failed: 0, + total, + }); + } + + eprintln!("Deleting {} duplicates with {} concurrent requests...\n", total, concurrency); + + // Set up progress bar + let progress = ProgressBar::new(total as u64); + progress.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}") + .unwrap() + .progress_chars("█▓░") + ); + + let deleted = Arc::new(AtomicU32::new(0)); + let failed = Arc::new(AtomicU32::new(0)); + + stream::iter(entries) + .for_each_concurrent(concurrency, |entry| { + let client = client.clone(); + let deleted = Arc::clone(&deleted); + let failed = Arc::clone(&failed); + let progress = progress.clone(); + async move { + match delete(&client, &entry.id).await { + Ok(_) => { + deleted.fetch_add(1, Ordering::Relaxed); + } + Err(_) => { + failed.fetch_add(1, Ordering::Relaxed); + } + } + progress.inc(1); + } + }) + .await; + + progress.finish_with_message("complete"); + + Ok(DeleteDuplicatesResult { + deleted: deleted.load(Ordering::Relaxed), + failed: failed.load(Ordering::Relaxed), + total, + }) +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 000000000..1fe5370a3 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,69 @@ +use reqwest::Client; +use serde::Deserialize; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AuthError { + #[error("Failed to connect to SSO server: {0}")] + ConnectionError(#[from] reqwest::Error), + + #[error("Authentication failed: Invalid client_id, client_secret, or SSO URL. Please verify your credentials.")] + AuthenticationFailed, + + #[error("SSO server returned an error: {0}")] + ServerError(String), +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + #[allow(dead_code)] + token_type: String, + #[allow(dead_code)] + expires_in: Option, +} + +#[derive(Deserialize)] +struct ErrorResponse { + error: String, + error_description: Option, +} + +/// Retrieves an OAuth2 access token using client credentials grant +pub async fn get_token(token_url: &str, client_id: &str, client_secret: &str) -> Result { + let client = Client::new(); + + let response = client + .post(token_url) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", client_id), + ("client_secret", client_secret), + ]) + .send() + .await?; + + if response.status().is_success() { + let token_response: TokenResponse = response.json().await?; + Ok(token_response.access_token) + } else if response.status().as_u16() == 401 || response.status().as_u16() == 400 { + // Try to get error details + if let Ok(error_response) = response.json::().await { + if error_response.error == "invalid_client" || error_response.error == "unauthorized_client" { + return Err(AuthError::AuthenticationFailed); + } + let msg = error_response + .error_description + .unwrap_or(error_response.error); + return Err(AuthError::ServerError(msg)); + } + Err(AuthError::AuthenticationFailed) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(AuthError::ServerError(format!( + "HTTP {}: {}", + status, body + ))) + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 000000000..fbcae8ae8 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,17 @@ +use clap::Parser; + +use crate::commands::Commands; +use crate::config::Config; + +/// Trustify CLI - Software Supply-Chain Security tool +#[derive(Parser)] +#[command(name = "trustify")] +#[command(about = "CLI for interacting with the Trustify API", long_about = None)] +#[command(version)] +pub struct Cli { + #[command(flatten)] + pub config: Config, + + #[command(subcommand)] + pub command: Commands, +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 000000000..2164b8d78 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,23 @@ +pub mod sbom; + +use clap::Subcommand; + +use crate::Context; +pub use sbom::SbomCommands; + +#[derive(Subcommand)] +pub enum Commands { + /// SBOM management commands + Sbom { + #[command(subcommand)] + command: SbomCommands, + }, +} + +impl Commands { + pub async fn run(&self, ctx: &Context) { + match self { + Commands::Sbom { command } => command.run(ctx).await, + } + } +} diff --git a/src/commands/sbom.rs b/src/commands/sbom.rs new file mode 100644 index 000000000..1ab27767e --- /dev/null +++ b/src/commands/sbom.rs @@ -0,0 +1,360 @@ +use std::io::{self, Write}; +use std::path::Path; +use std::process; + +use clap::{Subcommand, ValueEnum}; +use serde_json::Value; + +use crate::api::sbom as sbom_api; +use crate::Context; + +/// Output format for SBOM list +#[derive(Clone, Default, ValueEnum)] +pub enum ListFormat { + /// Only output the SBOM ID + Id, + /// Output id, name, document_id + Name, + /// Output id, name, document_id, ingested, published, size + Short, + /// Output complete JSON document + #[default] + Full, +} + +#[derive(Subcommand)] +pub enum DuplicatesCommands { + /// Find duplicates by namespace + Find { + /// Batch size for querying duplicates + #[arg(short = 'b', long, default_value = "100")] + batch_size: u32, + + /// Number of concurrent fetch requests (default: 4) + #[arg(short = 'j', long, default_value = "4")] + concurrency: usize, + + /// Output file + #[arg(long, default_value = "duplicates.json")] + output: Option, + }, + /// Delete duplicates + Delete { + /// Input file + #[arg(long, default_value = "duplicates.json")] + input: Option, + + /// Number of concurrent delete requests (default: 8) + #[arg(short = 'j', long, default_value = "8")] + concurrency: usize, + + /// Perform a dry run without actually deleting + #[arg(long)] + dry_run: bool, + }, +} + +#[derive(Subcommand)] +pub enum SbomCommands { + /// Get SBOM by ID + Get { + /// SBOM ID + id: String, + }, + /// List SBOMs + List { + /// Query filter for SBOMs + #[arg(long)] + query: Option, + /// Limit the number of results + #[arg(long)] + limit: Option, + /// Offset the results + #[arg(long)] + offset: Option, + /// Sort the results + /// Example: `purl:qualifiers:type:desc` + #[arg(long)] + sort: Option, + /// Output format: id, name, short, full (default: full) + #[arg(long, value_enum, default_value = "full")] + format: ListFormat, + }, + /// Delete SBOMs + Delete { + /// SBOM ID + #[arg(long)] + id: Option, + + /// Query filter for SBOMs to delete + #[arg(long)] + query: Option, + + /// Perform a dry run without actually deleting + #[arg(long)] + dry_run: bool, + }, + /// Manage duplicate SBOMs + Duplicates { + #[command(subcommand)] + command: DuplicatesCommands, + }, +} + +impl SbomCommands { + pub async fn run(&self, ctx: &Context) { + match self { + SbomCommands::Duplicates { command } => { + command.run(ctx).await; + } + SbomCommands::Get { id } => { + match sbom_api::get(&ctx.client, id).await { + Ok(json) => println!("{}", json), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + SbomCommands::List { + query, + limit, + offset, + sort, + format, + } => { + let params = sbom_api::ListParams { + q: query.clone(), + limit: *limit, + offset: *offset, + sort: sort.clone(), + }; + match sbom_api::list(&ctx.client, ¶ms).await { + Ok(json) => { + format_list_output(&json, format); + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + SbomCommands::Delete { + id, + query, + dry_run, + } => { + println!( + "SBOM delete command executed successfully!{}", + if *dry_run { " (dry-run)" } else { "" } + ); + if let Some(i) = id { + println!(" ID: {}", i); + } + if let Some(q) = query { + println!(" Query: {}", q); + } + } + } + } +} + +impl DuplicatesCommands { + pub async fn run(&self, ctx: &Context) { + match self { + DuplicatesCommands::Find { batch_size, concurrency, output } => { + let output_path = output.as_ref().map(|s| s.as_str()).unwrap_or("duplicates.json"); + + // Check if output file exists + let final_output = check_output_file(output_path); + if final_output.is_none() { + eprintln!("Operation cancelled."); + process::exit(0); + } + let final_output = final_output.unwrap(); + + let params = sbom_api::FindDuplicatesParams { + batch_size: *batch_size, + concurrency: *concurrency, + }; + match sbom_api::find_duplicates(&ctx.client, ¶ms, &Some(final_output.clone())).await { + Ok(groups) => { + let total_duplicates: usize = groups.iter().map(|g| g.duplicates.len()).sum(); + println!( + "Found {} document(s) with {} duplicate(s). Saved to {}", + groups.len(), + total_duplicates, + final_output + ); + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + DuplicatesCommands::Delete { + input, + concurrency, + dry_run, + } => { + let input_path = input.as_ref().map(|s| s.as_str()).unwrap_or("duplicates.json"); + + match sbom_api::delete_duplicates(&ctx.client, input_path, *concurrency, *dry_run).await { + Ok(result) => { + if *dry_run { + println!("[DRY-RUN] Would delete {} duplicate(s)", result.total); + } else { + println!( + "Deleted {} duplicate(s), {} failed out of {} total", + result.deleted, result.failed, result.total + ); + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + } + } +} + +/// Format and print list output based on the specified format +fn format_list_output(json: &str, format: &ListFormat) { + let parsed: Value = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + eprintln!("Error parsing response: {}", e); + process::exit(1); + } + }; + + // The API returns { "items": [...], "total": N } + let items = match parsed.get("items").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => { + // If no items array, just print the raw JSON + println!("{}", json); + return; + } + }; + + match format { + ListFormat::Full => { + println!("{}", json); + } + ListFormat::Id => { + for item in items { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + println!("{}", id); + } + } + } + ListFormat::Name => { + let result: Vec = items + .iter() + .map(|item| { + serde_json::json!({ + "id": item.get("id"), + "name": item.get("name"), + "document_id": item.get("document_id") + }) + }) + .collect(); + println!("{}", serde_json::to_string(&result).unwrap_or_default()); + } + ListFormat::Short => { + let result: Vec = items + .iter() + .map(|item| { + serde_json::json!({ + "id": item.get("id"), + "name": item.get("name"), + "document_id": item.get("document_id"), + "ingested": item.get("ingested"), + "published": item.get("published"), + "size": item.get("size"), + }) + }) + .collect(); + println!("{}", serde_json::to_string(&result).unwrap_or_default()); + } + } +} + +/// Check if output file exists and prompt user for action +/// Returns None if user cancels, Some(path) with the final path to use +fn check_output_file(output_path: &str) -> Option { + let path = Path::new(output_path); + + if !path.exists() { + return Some(output_path.to_string()); + } + + // File exists, ask user what to do + loop { + eprint!( + "File '{}' already exists. Overwrite? [y]es / [n]o / [r]ename: ", + output_path + ); + io::stderr().flush().ok(); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_err() { + return None; + } + + let input = input.trim().to_lowercase(); + match input.as_str() { + "y" | "yes" => { + return Some(output_path.to_string()); + } + "n" | "no" => { + return None; + } + "r" | "rename" => { + // Generate a new filename + let new_name = generate_unique_filename(output_path); + eprintln!("Using: {}", new_name); + return Some(new_name); + } + _ => { + eprintln!("Please enter 'y', 'n', or 'r'"); + } + } + } +} + +/// Generate a unique filename by appending a number +fn generate_unique_filename(base_path: &str) -> String { + let path = Path::new(base_path); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("duplicates"); + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("json"); + let parent = path.parent().and_then(|p| p.to_str()).unwrap_or(""); + + for i in 1..1000 { + let new_name = if parent.is_empty() { + format!("{}_{}.{}", stem, i, ext) + } else { + format!("{}/{}_{}.{}", parent, stem, i, ext) + }; + + if !Path::new(&new_name).exists() { + return new_name; + } + } + + // Fallback with timestamp + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if parent.is_empty() { + format!("{}_{}.{}", stem, timestamp, ext) + } else { + format!("{}/{}_{}.{}", parent, stem, timestamp, ext) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 000000000..bbfb852f4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,36 @@ +use clap::Args; + +/// Configuration for connecting to Trustify API +#[derive(Args, Clone)] +pub struct Config { + /// Trustify API URL (required) + #[arg(short = 'u', long = "url", env = "TRUSTIFY_URL")] + pub url: String, + + /// SSO URL for authentication + #[arg(long = "sso-url", env = "TRUSTIFY_SSO_URL")] + pub sso_url: Option, + + /// OAuth2 Client ID + #[arg(long = "client-id", env = "TRUSTIFY_CLIENT_ID")] + pub client_id: Option, + + /// OAuth2 Client Secret + #[arg(long = "client-secret", env = "TRUSTIFY_CLIENT_SECRET")] + pub client_secret: Option, +} + +impl Config { + /// Returns true if authentication credentials are configured + pub fn has_auth(&self) -> bool { + self.sso_url.is_some() && self.client_id.is_some() && self.client_secret.is_some() + } + + /// Returns the auth credentials if all are present + pub fn auth_credentials(&self) -> Option<(&str, &str, &str)> { + match (&self.sso_url, &self.client_id, &self.client_secret) { + (Some(sso), Some(id), Some(secret)) => Some((sso.as_str(), id.as_str(), secret.as_str())), + _ => None, + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 000000000..dd5d26f6c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,56 @@ +mod api; +mod auth; +mod cli; +mod commands; +mod config; + +use std::process; + +use clap::Parser; + +use api::ApiClient; +use cli::Cli; + +/// Runtime context containing config and API client +pub struct Context { + pub config: config::Config, + pub client: ApiClient, +} + +#[tokio::main] +async fn main() { + // Load .env file if present (silently ignore if not found) + let _ = dotenvy::dotenv(); + + let cli = Cli::parse(); + + // Attempt authentication if credentials are provided + let token = if let Some((sso_url, client_id, client_secret)) = cli.config.auth_credentials() { + let token_url = if sso_url.ends_with("/token") { + sso_url.to_string() + } else if sso_url.ends_with('/') { + format!("{}protocol/openid-connect/token", sso_url) + } else { + format!("{}/protocol/openid-connect/token", sso_url) + }; + match auth::get_token(&token_url, client_id, client_secret).await { + Ok(token) => Some(token), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } else { + None + }; + + // Create API client + let client = ApiClient::new(&cli.config.url, token); + + let ctx = Context { + config: cli.config, + client, + }; + + cli.command.run(&ctx).await; +} From a46b517262522e163a621ca0d8b2bacea8015b10 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 12 Jan 2026 20:03:33 +0100 Subject: [PATCH 02/12] chore: improve error handling and reauth Signed-off-by: Ruben Romero Montes Assisted-by: Cursor --- src/api/client.rs | 182 +++++++++++++++++++++++++++++++++++-------- src/api/sbom.rs | 149 +++++++++++++++++++++-------------- src/auth.rs | 15 ++-- src/commands/sbom.rs | 69 +++++++++------- src/config.rs | 4 +- src/main.rs | 48 +++++++----- 6 files changed, 325 insertions(+), 142 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index e0910bd10..2538e2dd2 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -1,10 +1,20 @@ -use reqwest::{Client, RequestBuilder}; +use std::sync::Arc; +use std::time::Duration; + +use reqwest::{Client, RequestBuilder, StatusCode}; use thiserror::Error; +use tokio::sync::RwLock; +use tokio::time::sleep; + +use crate::auth; + +const MAX_RETRIES: u32 = 3; +const RETRY_DELAY_MS: u64 = 1000; -#[derive(Error, Debug)] +#[derive(Error, Debug, Clone)] pub enum ApiError { #[error("Request failed: {0}")] - RequestError(#[from] reqwest::Error), + RequestError(String), #[error("Not found: {0}")] NotFound(String), @@ -12,27 +22,59 @@ pub enum ApiError { #[error("Unauthorized: Please check your authentication credentials")] Unauthorized, + #[error("Token expired")] + TokenExpired, + + #[error("Server timeout - please retry")] + Timeout, + #[error("Server error: {0}")] ServerError(String), } -/// API client for Trustify +impl From for ApiError { + fn from(e: reqwest::Error) -> Self { + if e.is_timeout() { + ApiError::Timeout + } else { + ApiError::RequestError(e.to_string()) + } + } +} + +/// Authentication credentials for token refresh +#[derive(Clone)] +pub struct AuthCredentials { + pub token_url: String, + pub client_id: String, + pub client_secret: String, +} + +/// API client for Trustify with retry and token refresh support #[derive(Clone)] pub struct ApiClient { client: Client, base_url: String, - token: Option, + token: Arc>>, + auth_credentials: Option, } impl ApiClient { - pub fn new(base_url: &str, token: Option) -> Self { - // Normalize base URL (remove trailing slash) + pub fn new( + base_url: &str, + token: Option, + auth_credentials: Option, + ) -> Self { let base_url = base_url.trim_end_matches('/').to_string(); Self { - client: Client::new(), + client: Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .unwrap_or_else(|_| Client::new()), base_url, - token, + token: Arc::new(RwLock::new(token)), + auth_credentials, } } @@ -42,42 +84,114 @@ impl ApiClient { } /// Add authorization header if token is present - fn authorize(&self, request: RequestBuilder) -> RequestBuilder { - match &self.token { - Some(token) => request.bearer_auth(token), + async fn authorize(&self, request: RequestBuilder) -> RequestBuilder { + let token = self.token.read().await; + match &*token { + Some(t) => request.bearer_auth(t), None => request, } } - /// Perform a GET request - pub async fn get(&self, path: &str) -> Result { - let url = self.url(path); - let request = self.client.get(&url); - let response = self.authorize(request).send().await?; + /// Refresh the token using stored credentials + async fn refresh_token(&self) -> Result<(), ApiError> { + let creds = self + .auth_credentials + .as_ref() + .ok_or(ApiError::Unauthorized)?; + + eprintln!("Token expired, refreshing..."); + + match auth::get_token(&creds.token_url, &creds.client_id, &creds.client_secret).await { + Ok(new_token) => { + let mut token = self.token.write().await; + *token = Some(new_token); + eprintln!("Token refreshed successfully"); + Ok(()) + } + Err(e) => { + eprintln!("Failed to refresh token: {}", e); + Err(ApiError::Unauthorized) + } + } + } - self.handle_response(response).await + /// Perform a GET request with retry logic + pub async fn get(&self, path: &str) -> Result { + self.execute_with_retry(|| async { + let url = self.url(path); + let request = self.client.get(&url); + let response = self.authorize(request).await.send().await?; + self.handle_response(response).await + }) + .await } - /// Perform a GET request with query parameters - pub async fn get_with_query( + /// Perform a GET request with query parameters and retry logic + pub async fn get_with_query( &self, path: &str, query: &T, ) -> Result { - let url = self.url(path); - let request = self.client.get(&url).query(query); - let response = self.authorize(request).send().await?; - - self.handle_response(response).await + self.execute_with_retry(|| async { + let url = self.url(path); + let request = self.client.get(&url).query(query); + let response = self.authorize(request).await.send().await?; + self.handle_response(response).await + }) + .await } - /// Perform a DELETE request + /// Perform a DELETE request with retry logic pub async fn delete(&self, path: &str) -> Result { - let url = self.url(path); - let request = self.client.delete(&url); - let response = self.authorize(request).send().await?; + self.execute_with_retry(|| async { + let url = self.url(path); + let request = self.client.delete(&url); + let response = self.authorize(request).await.send().await?; + self.handle_response(response).await + }) + .await + } + + /// Execute a request with retry logic for timeouts and token refresh + async fn execute_with_retry(&self, f: F) -> Result + where + F: Fn() -> Fut, + Fut: std::future::Future>, + { + let mut last_error = ApiError::RequestError("No attempts made".to_string()); + let mut token_refreshed = false; + + for attempt in 0..MAX_RETRIES { + match f().await { + Ok(result) => return Ok(result), + Err(ApiError::TokenExpired) => { + if !token_refreshed + && self.auth_credentials.is_some() + && self.refresh_token().await.is_ok() + { + token_refreshed = true; + continue; // Retry with new token + } + return Err(ApiError::Unauthorized); + } + Err(ApiError::Timeout) | Err(ApiError::ServerError(_)) + if attempt < MAX_RETRIES - 1 => + { + let delay = RETRY_DELAY_MS * (attempt as u64 + 1); + eprintln!( + "Request failed, retrying in {}ms... (attempt {}/{})", + delay, + attempt + 1, + MAX_RETRIES + ); + sleep(Duration::from_millis(delay)).await; + last_error = ApiError::Timeout; + } + Err(e) => return Err(e), + } + } - self.handle_response(response).await + Err(last_error) } async fn handle_response(&self, response: reqwest::Response) -> Result { @@ -85,10 +199,14 @@ impl ApiClient { if status.is_success() { Ok(response.text().await?) - } else if status.as_u16() == 404 { + } else if status == StatusCode::NOT_FOUND { Err(ApiError::NotFound("Resource not found".to_string())) - } else if status.as_u16() == 401 || status.as_u16() == 403 { + } else if status == StatusCode::UNAUTHORIZED { + Err(ApiError::TokenExpired) + } else if status == StatusCode::FORBIDDEN { Err(ApiError::Unauthorized) + } else if status == StatusCode::GATEWAY_TIMEOUT || status == StatusCode::REQUEST_TIMEOUT { + Err(ApiError::Timeout) } else { let body = response.text().await.unwrap_or_default(); Err(ApiError::ServerError(format!("HTTP {}: {}", status, body))) diff --git a/src/api/sbom.rs b/src/api/sbom.rs index 12b52ae7a..c531d8c4a 100644 --- a/src/api/sbom.rs +++ b/src/api/sbom.rs @@ -64,7 +64,11 @@ pub async fn list(client: &ApiClient, params: &ListParams) -> Result Result, ApiError> { +async fn fetch_page( + client: &ApiClient, + batch_size: u32, + offset: u32, +) -> Result, ApiError> { let list_params = ListParams { q: None, limit: Some(batch_size), @@ -73,9 +77,8 @@ async fn fetch_page(client: &ApiClient, batch_size: u32, offset: u32) -> Result< }; let response = list(client, &list_params).await?; - let parsed: Value = serde_json::from_str(&response).map_err(|e| { - ApiError::ServerError(format!("Failed to parse response: {}", e)) - })?; + let parsed: Value = serde_json::from_str(&response) + .map_err(|e| ApiError::ServerError(format!("Failed to parse response: {}", e)))?; let items = parsed .get("items") @@ -86,13 +89,23 @@ async fn fetch_page(client: &ApiClient, batch_size: u32, offset: u32) -> Result< .iter() .filter_map(|item| { let id = item.get("id").and_then(|v| v.as_str())?.to_string(); - let document_id = item.get("document_id").and_then(|v| v.as_str())?.to_string(); - let published = item.get("published").and_then(|v| v.as_str()).map(|s| s.to_string()); - + let document_id = item + .get("document_id") + .and_then(|v| v.as_str())? + .to_string(); + let published = item + .get("published") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if document_id.is_empty() { None } else { - Some(SbomEntry { id, document_id, published }) + Some(SbomEntry { + id, + document_id, + published, + }) } }) .collect(); @@ -110,7 +123,7 @@ async fn fetch_worker( results: Arc>>, ) { let mut fetched: u64 = 0; - + for offset in pages { match fetch_page(&client, batch_size, offset).await { Ok(entries) => { @@ -119,11 +132,14 @@ async fn fetch_worker( results.lock().await.extend(entries); } Err(e) => { - progress_bar.println(format!("Worker {}: Error at offset {}: {}", worker_id, offset, e)); + progress_bar.println(format!( + "Worker {}: Error at offset {}: {}", + worker_id, offset, e + )); } } } - + progress_bar.finish_with_message("done"); } @@ -137,21 +153,21 @@ pub async fn find_duplicates( let concurrency = params.concurrency; // First, get the total count - let first_page = list(client, &ListParams { - q: None, - limit: Some(1), - offset: Some(0), - sort: None, - }).await?; - - let parsed: Value = serde_json::from_str(&first_page).map_err(|e| { - ApiError::ServerError(format!("Failed to parse response: {}", e)) - })?; - - let total = parsed - .get("total") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; + let first_page = list( + client, + &ListParams { + q: None, + limit: Some(1), + offset: Some(0), + sort: None, + }, + ) + .await?; + + let parsed: Value = serde_json::from_str(&first_page) + .map_err(|e| ApiError::ServerError(format!("Failed to parse response: {}", e)))?; + + let total = parsed.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32; if total == 0 { eprintln!("No SBOMs found"); @@ -171,12 +187,18 @@ pub async fn find_duplicates( } // Calculate how many SBOMs each worker will fetch - let worker_counts: Vec = worker_pages.iter().map(|pages| { - pages.iter().map(|&offset| { - let remaining = total.saturating_sub(offset); - remaining.min(batch_size) as u64 - }).sum() - }).collect(); + let worker_counts: Vec = worker_pages + .iter() + .map(|pages| { + pages + .iter() + .map(|&offset| { + let remaining = total.saturating_sub(offset); + remaining.min(batch_size) as u64 + }) + .sum() + }) + .collect(); // Set up progress bars let multi_progress = MultiProgress::new(); @@ -239,13 +261,11 @@ pub async fn find_duplicates( } // Sort by published date descending (most recent first) - entries.sort_by(|a, b| { - match (&b.published, &a.published) { - (Some(b_pub), Some(a_pub)) => b_pub.cmp(a_pub), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - } + entries.sort_by(|a, b| match (&b.published, &a.published) { + (Some(b_pub), Some(a_pub)) => b_pub.cmp(a_pub), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, }); let most_recent = entries.remove(0); @@ -259,7 +279,10 @@ pub async fn find_duplicates( }); } - eprintln!("Found {} document(s) with duplicates", duplicate_groups.len()); + eprintln!( + "Found {} document(s) with duplicates", + duplicate_groups.len() + ); // Save to file let output_path = output_file @@ -267,17 +290,14 @@ pub async fn find_duplicates( .map(|s| s.as_str()) .unwrap_or("duplicates.json"); - let json = serde_json::to_string_pretty(&duplicate_groups).map_err(|e| { - ApiError::ServerError(format!("Failed to serialize results: {}", e)) - })?; + let json = serde_json::to_string_pretty(&duplicate_groups) + .map_err(|e| ApiError::ServerError(format!("Failed to serialize results: {}", e)))?; - let mut file = File::create(output_path).map_err(|e| { - ApiError::ServerError(format!("Failed to create output file: {}", e)) - })?; + let mut file = File::create(output_path) + .map_err(|e| ApiError::ServerError(format!("Failed to create output file: {}", e)))?; - file.write_all(json.as_bytes()).map_err(|e| { - ApiError::ServerError(format!("Failed to write to file: {}", e)) - })?; + file.write_all(json.as_bytes()) + .map_err(|e| ApiError::ServerError(format!("Failed to write to file: {}", e)))?; Ok(duplicate_groups) } @@ -292,6 +312,7 @@ pub async fn delete(client: &ApiClient, id: &str) -> Result<(), ApiError> { /// Result of deleting duplicates pub struct DeleteDuplicatesResult { pub deleted: u32, + pub skipped: u32, pub failed: u32, pub total: u32, } @@ -320,14 +341,12 @@ pub async fn delete_duplicates( } // Read and parse the file - let file = File::open(path).map_err(|e| { - ApiError::ServerError(format!("Failed to open input file: {}", e)) - })?; + let file = File::open(path) + .map_err(|e| ApiError::ServerError(format!("Failed to open input file: {}", e)))?; let reader = BufReader::new(file); - let groups: Vec = serde_json::from_reader(reader).map_err(|e| { - ApiError::ServerError(format!("Failed to parse input file: {}", e)) - })?; + let groups: Vec = serde_json::from_reader(reader) + .map_err(|e| ApiError::ServerError(format!("Failed to parse input file: {}", e)))?; // Collect all duplicate entries to delete let entries: Vec = groups @@ -344,16 +363,23 @@ pub async fn delete_duplicates( if dry_run { for entry in &entries { - eprintln!("[DRY-RUN] Would delete: {} (document_id: {})", entry.id, entry.document_id); + eprintln!( + "[DRY-RUN] Would delete: {} (document_id: {})", + entry.id, entry.document_id + ); } return Ok(DeleteDuplicatesResult { deleted: 0, + skipped: 0, failed: 0, total, }); } - eprintln!("Deleting {} duplicates with {} concurrent requests...\n", total, concurrency); + eprintln!( + "Deleting {} duplicates with {} concurrent requests...\n", + total, concurrency + ); // Set up progress bar let progress = ProgressBar::new(total as u64); @@ -361,16 +387,18 @@ pub async fn delete_duplicates( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}") .unwrap() - .progress_chars("█▓░") + .progress_chars("█▓░"), ); let deleted = Arc::new(AtomicU32::new(0)); + let skipped = Arc::new(AtomicU32::new(0)); let failed = Arc::new(AtomicU32::new(0)); stream::iter(entries) .for_each_concurrent(concurrency, |entry| { let client = client.clone(); let deleted = Arc::clone(&deleted); + let skipped = Arc::clone(&skipped); let failed = Arc::clone(&failed); let progress = progress.clone(); async move { @@ -378,6 +406,10 @@ pub async fn delete_duplicates( Ok(_) => { deleted.fetch_add(1, Ordering::Relaxed); } + Err(ApiError::NotFound(_)) => { + // SBOM already deleted or doesn't exist - skip silently + skipped.fetch_add(1, Ordering::Relaxed); + } Err(_) => { failed.fetch_add(1, Ordering::Relaxed); } @@ -391,6 +423,7 @@ pub async fn delete_duplicates( Ok(DeleteDuplicatesResult { deleted: deleted.load(Ordering::Relaxed), + skipped: skipped.load(Ordering::Relaxed), failed: failed.load(Ordering::Relaxed), total, }) diff --git a/src/auth.rs b/src/auth.rs index 1fe5370a3..e7c2396fd 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -30,7 +30,11 @@ struct ErrorResponse { } /// Retrieves an OAuth2 access token using client credentials grant -pub async fn get_token(token_url: &str, client_id: &str, client_secret: &str) -> Result { +pub async fn get_token( + token_url: &str, + client_id: &str, + client_secret: &str, +) -> Result { let client = Client::new(); let response = client @@ -49,7 +53,9 @@ pub async fn get_token(token_url: &str, client_id: &str, client_secret: &str) -> } else if response.status().as_u16() == 401 || response.status().as_u16() == 400 { // Try to get error details if let Ok(error_response) = response.json::().await { - if error_response.error == "invalid_client" || error_response.error == "unauthorized_client" { + if error_response.error == "invalid_client" + || error_response.error == "unauthorized_client" + { return Err(AuthError::AuthenticationFailed); } let msg = error_response @@ -61,9 +67,6 @@ pub async fn get_token(token_url: &str, client_id: &str, client_secret: &str) -> } else { let status = response.status(); let body = response.text().await.unwrap_or_default(); - Err(AuthError::ServerError(format!( - "HTTP {}: {}", - status, body - ))) + Err(AuthError::ServerError(format!("HTTP {}: {}", status, body))) } } diff --git a/src/commands/sbom.rs b/src/commands/sbom.rs index 1ab27767e..cb79d51d9 100644 --- a/src/commands/sbom.rs +++ b/src/commands/sbom.rs @@ -107,15 +107,13 @@ impl SbomCommands { SbomCommands::Duplicates { command } => { command.run(ctx).await; } - SbomCommands::Get { id } => { - match sbom_api::get(&ctx.client, id).await { - Ok(json) => println!("{}", json), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } + SbomCommands::Get { id } => match sbom_api::get(&ctx.client, id).await { + Ok(json) => println!("{}", json), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); } - } + }, SbomCommands::List { query, limit, @@ -139,11 +137,7 @@ impl SbomCommands { } } } - SbomCommands::Delete { - id, - query, - dry_run, - } => { + SbomCommands::Delete { id, query, dry_run } => { println!( "SBOM delete command executed successfully!{}", if *dry_run { " (dry-run)" } else { "" } @@ -162,9 +156,16 @@ impl SbomCommands { impl DuplicatesCommands { pub async fn run(&self, ctx: &Context) { match self { - DuplicatesCommands::Find { batch_size, concurrency, output } => { - let output_path = output.as_ref().map(|s| s.as_str()).unwrap_or("duplicates.json"); - + DuplicatesCommands::Find { + batch_size, + concurrency, + output, + } => { + let output_path = output + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("duplicates.json"); + // Check if output file exists let final_output = check_output_file(output_path); if final_output.is_none() { @@ -177,9 +178,12 @@ impl DuplicatesCommands { batch_size: *batch_size, concurrency: *concurrency, }; - match sbom_api::find_duplicates(&ctx.client, ¶ms, &Some(final_output.clone())).await { + match sbom_api::find_duplicates(&ctx.client, ¶ms, &Some(final_output.clone())) + .await + { Ok(groups) => { - let total_duplicates: usize = groups.iter().map(|g| g.duplicates.len()).sum(); + let total_duplicates: usize = + groups.iter().map(|g| g.duplicates.len()).sum(); println!( "Found {} document(s) with {} duplicate(s). Saved to {}", groups.len(), @@ -198,17 +202,27 @@ impl DuplicatesCommands { concurrency, dry_run, } => { - let input_path = input.as_ref().map(|s| s.as_str()).unwrap_or("duplicates.json"); + let input_path = input + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("duplicates.json"); - match sbom_api::delete_duplicates(&ctx.client, input_path, *concurrency, *dry_run).await { + match sbom_api::delete_duplicates(&ctx.client, input_path, *concurrency, *dry_run) + .await + { Ok(result) => { if *dry_run { println!("[DRY-RUN] Would delete {} duplicate(s)", result.total); } else { - println!( - "Deleted {} duplicate(s), {} failed out of {} total", - result.deleted, result.failed, result.total - ); + let mut msg = format!("Deleted {} duplicate(s)", result.deleted); + if result.skipped > 0 { + msg.push_str(&format!(", {} skipped (not found)", result.skipped)); + } + if result.failed > 0 { + msg.push_str(&format!(", {} failed", result.failed)); + } + msg.push_str(&format!(" out of {} total", result.total)); + println!("{}", msg); } } Err(e) => { @@ -288,7 +302,7 @@ fn format_list_output(json: &str, format: &ListFormat) { /// Returns None if user cancels, Some(path) with the final path to use fn check_output_file(output_path: &str) -> Option { let path = Path::new(output_path); - + if !path.exists() { return Some(output_path.to_string()); } @@ -330,7 +344,10 @@ fn check_output_file(output_path: &str) -> Option { /// Generate a unique filename by appending a number fn generate_unique_filename(base_path: &str) -> String { let path = Path::new(base_path); - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("duplicates"); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("duplicates"); let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("json"); let parent = path.parent().and_then(|p| p.to_str()).unwrap_or(""); diff --git a/src/config.rs b/src/config.rs index bbfb852f4..b11e299a8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,7 +29,9 @@ impl Config { /// Returns the auth credentials if all are present pub fn auth_credentials(&self) -> Option<(&str, &str, &str)> { match (&self.sso_url, &self.client_id, &self.client_secret) { - (Some(sso), Some(id), Some(secret)) => Some((sso.as_str(), id.as_str(), secret.as_str())), + (Some(sso), Some(id), Some(secret)) => { + Some((sso.as_str(), id.as_str(), secret.as_str())) + } _ => None, } } diff --git a/src/main.rs b/src/main.rs index dd5d26f6c..603fe230f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use std::process; use clap::Parser; +use api::client::AuthCredentials; use api::ApiClient; use cli::Cli; @@ -24,28 +25,37 @@ async fn main() { let cli = Cli::parse(); - // Attempt authentication if credentials are provided - let token = if let Some((sso_url, client_id, client_secret)) = cli.config.auth_credentials() { - let token_url = if sso_url.ends_with("/token") { - sso_url.to_string() - } else if sso_url.ends_with('/') { - format!("{}protocol/openid-connect/token", sso_url) + // Build auth credentials and get initial token if configured + let (token, auth_credentials) = + if let Some((sso_url, client_id, client_secret)) = cli.config.auth_credentials() { + let token_url = if sso_url.ends_with("/token") { + sso_url.to_string() + } else if sso_url.ends_with('/') { + format!("{}protocol/openid-connect/token", sso_url) + } else { + format!("{}/protocol/openid-connect/token", sso_url) + }; + + // Store credentials for token refresh + let creds = AuthCredentials { + token_url: token_url.clone(), + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + }; + + match auth::get_token(&token_url, client_id, client_secret).await { + Ok(token) => (Some(token), Some(creds)), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } } else { - format!("{}/protocol/openid-connect/token", sso_url) + (None, None) }; - match auth::get_token(&token_url, client_id, client_secret).await { - Ok(token) => Some(token), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } - } else { - None - }; - // Create API client - let client = ApiClient::new(&cli.config.url, token); + // Create API client with auth credentials for token refresh + let client = ApiClient::new(&cli.config.url, token, auth_credentials); let ctx = Context { config: cli.config, From edecbd950c8638526c2084e34e45a7e643cab8b0 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 12 Jan 2026 21:51:26 +0100 Subject: [PATCH 03/12] chore: publish container image Signed-off-by: Ruben Romero Montes Assisted-by: Cursor --- .dockerignore | 2 +- .github/workflows/container-image.yml | 62 +++++++++++++++++++++++++++ Dockerfile | 31 ++++++++++++++ src/api/client.rs | 52 ++++++++++++++-------- src/api/sbom.rs | 26 ++++++----- 5 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/container-image.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore index 2ac0bebaa..2a6c05b7f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,4 @@ .trustify /target /.dockerignore -/Containerfile +/Containerfile \ No newline at end of file diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml new file mode 100644 index 000000000..91ae026a3 --- /dev/null +++ b/.github/workflows/container-image.yml @@ -0,0 +1,62 @@ +name: Build and Push Container Image + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,prefix= + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..d347217e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM rust:1.83-alpine AS builder + +# Install musl-dev for static linking +RUN apk add --no-cache musl-dev + +WORKDIR /app + +# Copy manifests first for better layer caching +COPY Cargo.toml Cargo.lock ./ + +# Create a dummy main.rs to build dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs + +# Build dependencies only (this layer will be cached) +RUN cargo build --release && rm -rf src + +# Copy actual source code +COPY src ./src + +# Build the actual binary (touch to update mtime so cargo rebuilds) +RUN touch src/main.rs && cargo build --release + +# Runtime stage - use minimal distroless image +FROM gcr.io/distroless/static-debian12:nonroot + +# Copy the binary from builder +COPY --from=builder /app/target/release/trustify /usr/local/bin/trustify + +# Set the entrypoint +ENTRYPOINT ["trustify"] diff --git a/src/api/client.rs b/src/api/client.rs index 2538e2dd2..f4a0afeb7 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -13,31 +13,41 @@ const RETRY_DELAY_MS: u64 = 1000; #[derive(Error, Debug, Clone)] pub enum ApiError { - #[error("Request failed: {0}")] - RequestError(String), + #[error("Network error: {0}")] + NetworkError(String), - #[error("Not found: {0}")] + #[error("HTTP {0}: {1}")] + HttpError(u16, String), + + #[error("HTTP 404: Resource not found")] NotFound(String), - #[error("Unauthorized: Please check your authentication credentials")] + #[error("HTTP 401: Please check your authentication credentials")] Unauthorized, - #[error("Token expired")] + #[error("HTTP 401: Token expired")] TokenExpired, - #[error("Server timeout - please retry")] - Timeout, + #[error("HTTP {0}: Server timeout")] + Timeout(u16), + + #[error("HTTP {0}: {1}")] + ServerError(u16, String), - #[error("Server error: {0}")] - ServerError(String), + #[error("{0}")] + InternalError(String), } impl From for ApiError { fn from(e: reqwest::Error) -> Self { if e.is_timeout() { - ApiError::Timeout + ApiError::Timeout(0) // 0 indicates network-level timeout (no HTTP response) + } else if e.is_connect() { + ApiError::NetworkError(format!("Connection failed: {}", e)) + } else if e.is_request() { + ApiError::NetworkError(format!("Request error: {}", e)) } else { - ApiError::RequestError(e.to_string()) + ApiError::NetworkError(e.to_string()) } } } @@ -158,7 +168,7 @@ impl ApiClient { F: Fn() -> Fut, Fut: std::future::Future>, { - let mut last_error = ApiError::RequestError("No attempts made".to_string()); + let mut last_error = ApiError::NetworkError("No attempts made".to_string()); let mut token_refreshed = false; for attempt in 0..MAX_RETRIES { @@ -174,18 +184,21 @@ impl ApiClient { } return Err(ApiError::Unauthorized); } - Err(ApiError::Timeout) | Err(ApiError::ServerError(_)) + Err(ref e @ ApiError::Timeout(_)) + | Err(ref e @ ApiError::ServerError(_, _)) + | Err(ref e @ ApiError::NetworkError(_)) if attempt < MAX_RETRIES - 1 => { let delay = RETRY_DELAY_MS * (attempt as u64 + 1); eprintln!( - "Request failed, retrying in {}ms... (attempt {}/{})", + "{}, retrying in {}ms... (attempt {}/{})", + e, delay, attempt + 1, MAX_RETRIES ); sleep(Duration::from_millis(delay)).await; - last_error = ApiError::Timeout; + last_error = e.clone(); } Err(e) => return Err(e), } @@ -196,6 +209,7 @@ impl ApiClient { async fn handle_response(&self, response: reqwest::Response) -> Result { let status = response.status(); + let status_code = status.as_u16(); if status.is_success() { Ok(response.text().await?) @@ -206,10 +220,14 @@ impl ApiClient { } else if status == StatusCode::FORBIDDEN { Err(ApiError::Unauthorized) } else if status == StatusCode::GATEWAY_TIMEOUT || status == StatusCode::REQUEST_TIMEOUT { - Err(ApiError::Timeout) + Err(ApiError::Timeout(status_code)) } else { let body = response.text().await.unwrap_or_default(); - Err(ApiError::ServerError(format!("HTTP {}: {}", status, body))) + if status.is_server_error() { + Err(ApiError::ServerError(status_code, body)) + } else { + Err(ApiError::HttpError(status_code, body)) + } } } } diff --git a/src/api/sbom.rs b/src/api/sbom.rs index c531d8c4a..57d490543 100644 --- a/src/api/sbom.rs +++ b/src/api/sbom.rs @@ -78,12 +78,12 @@ async fn fetch_page( let response = list(client, &list_params).await?; let parsed: Value = serde_json::from_str(&response) - .map_err(|e| ApiError::ServerError(format!("Failed to parse response: {}", e)))?; + .map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?; let items = parsed .get("items") .and_then(|v| v.as_array()) - .ok_or_else(|| ApiError::ServerError("No items in response".to_string()))?; + .ok_or_else(|| ApiError::InternalError("No items in response".to_string()))?; let entries: Vec = items .iter() @@ -165,7 +165,7 @@ pub async fn find_duplicates( .await?; let parsed: Value = serde_json::from_str(&first_page) - .map_err(|e| ApiError::ServerError(format!("Failed to parse response: {}", e)))?; + .map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?; let total = parsed.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32; @@ -238,7 +238,7 @@ pub async fn find_duplicates( join_all(handles).await; let all_entries = Arc::try_unwrap(results) - .map_err(|_| ApiError::ServerError("Failed to unwrap entries".to_string()))? + .map_err(|_| ApiError::InternalError("Failed to unwrap entries".to_string()))? .into_inner(); eprintln!("\nProcessing {} SBOMs for duplicates...", all_entries.len()); @@ -291,13 +291,13 @@ pub async fn find_duplicates( .unwrap_or("duplicates.json"); let json = serde_json::to_string_pretty(&duplicate_groups) - .map_err(|e| ApiError::ServerError(format!("Failed to serialize results: {}", e)))?; + .map_err(|e| ApiError::InternalError(format!("Failed to serialize results: {}", e)))?; let mut file = File::create(output_path) - .map_err(|e| ApiError::ServerError(format!("Failed to create output file: {}", e)))?; + .map_err(|e| ApiError::InternalError(format!("Failed to create output file: {}", e)))?; file.write_all(json.as_bytes()) - .map_err(|e| ApiError::ServerError(format!("Failed to write to file: {}", e)))?; + .map_err(|e| ApiError::InternalError(format!("Failed to write to file: {}", e)))?; Ok(duplicate_groups) } @@ -334,7 +334,7 @@ pub async fn delete_duplicates( // Check if file exists let path = Path::new(input_file); if !path.exists() { - return Err(ApiError::ServerError(format!( + return Err(ApiError::InternalError(format!( "Input file not found: {}", input_file ))); @@ -342,11 +342,11 @@ pub async fn delete_duplicates( // Read and parse the file let file = File::open(path) - .map_err(|e| ApiError::ServerError(format!("Failed to open input file: {}", e)))?; + .map_err(|e| ApiError::InternalError(format!("Failed to open input file: {}", e)))?; let reader = BufReader::new(file); let groups: Vec = serde_json::from_reader(reader) - .map_err(|e| ApiError::ServerError(format!("Failed to parse input file: {}", e)))?; + .map_err(|e| ApiError::InternalError(format!("Failed to parse input file: {}", e)))?; // Collect all duplicate entries to delete let entries: Vec = groups @@ -410,8 +410,12 @@ pub async fn delete_duplicates( // SBOM already deleted or doesn't exist - skip silently skipped.fetch_add(1, Ordering::Relaxed); } - Err(_) => { + Err(e) => { failed.fetch_add(1, Ordering::Relaxed); + progress.println(format!( + "Failed to delete {} (document_id: {}): {}", + entry.id, entry.document_id, e + )); } } progress.inc(1); From ec34ba85c4aee299f955f31668b4e55cf61f2235 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 13 Jan 2026 10:05:01 +0100 Subject: [PATCH 04/12] feat: add auth token command Signed-off-by: Ruben Romero Montes Assisted-by: Cursor --- src/{ => api}/auth.rs | 36 ++++++++++++++++++++++++++++++++++++ src/api/client.rs | 12 +++--------- src/api/mod.rs | 2 +- src/commands/auth.rs | 36 ++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 9 +++++++++ src/main.rs | 20 +++----------------- 6 files changed, 88 insertions(+), 27 deletions(-) rename src/{ => api}/auth.rs (66%) create mode 100644 src/commands/auth.rs diff --git a/src/auth.rs b/src/api/auth.rs similarity index 66% rename from src/auth.rs rename to src/api/auth.rs index e7c2396fd..79199ca66 100644 --- a/src/auth.rs +++ b/src/api/auth.rs @@ -14,6 +14,42 @@ pub enum AuthError { ServerError(String), } +/// Authentication credentials for token refresh +#[derive(Clone)] +pub struct AuthCredentials { + pub token_url: String, + pub client_id: String, + pub client_secret: String, +} + +impl AuthCredentials { + /// Build credentials from SSO URL and client credentials + pub fn new(sso_url: &str, client_id: &str, client_secret: &str) -> Self { + let token_url = build_token_url(sso_url); + Self { + token_url, + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + } + } + + /// Get a token using these credentials + pub async fn get_token(&self) -> Result { + get_token(&self.token_url, &self.client_id, &self.client_secret).await + } +} + +/// Build the token URL from an SSO base URL +pub fn build_token_url(sso_url: &str) -> String { + if sso_url.ends_with("/token") { + sso_url.to_string() + } else if sso_url.ends_with('/') { + format!("{}protocol/openid-connect/token", sso_url) + } else { + format!("{}/protocol/openid-connect/token", sso_url) + } +} + #[derive(Deserialize)] struct TokenResponse { access_token: String, diff --git a/src/api/client.rs b/src/api/client.rs index f4a0afeb7..d06704d0f 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -6,7 +6,6 @@ use thiserror::Error; use tokio::sync::RwLock; use tokio::time::sleep; -use crate::auth; const MAX_RETRIES: u32 = 3; const RETRY_DELAY_MS: u64 = 1000; @@ -52,13 +51,8 @@ impl From for ApiError { } } -/// Authentication credentials for token refresh -#[derive(Clone)] -pub struct AuthCredentials { - pub token_url: String, - pub client_id: String, - pub client_secret: String, -} +// Re-export AuthCredentials from auth module +pub use super::auth::AuthCredentials; /// API client for Trustify with retry and token refresh support #[derive(Clone)] @@ -111,7 +105,7 @@ impl ApiClient { eprintln!("Token expired, refreshing..."); - match auth::get_token(&creds.token_url, &creds.client_id, &creds.client_secret).await { + match creds.get_token().await { Ok(new_token) => { let mut token = self.token.write().await; *token = Some(new_token); diff --git a/src/api/mod.rs b/src/api/mod.rs index 40637dfd2..666dc0fd0 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,4 @@ pub mod client; pub mod sbom; - +pub mod auth; pub use client::ApiClient; diff --git a/src/commands/auth.rs b/src/commands/auth.rs new file mode 100644 index 000000000..7ca608fb5 --- /dev/null +++ b/src/commands/auth.rs @@ -0,0 +1,36 @@ +use clap::Subcommand; + +use std::process; +use crate::Context; +use crate::api::auth::AuthCredentials; + +#[derive(Subcommand)] +pub enum AuthCommands { + /// Get authentication token + Token {}, +} + +impl AuthCommands { + pub async fn run(&self, ctx: &Context) { + match self { + AuthCommands::Token {} => { + match ctx.config.auth_credentials() { + Some((token_url, client_id, client_secret)) => { + let creds = AuthCredentials::new(token_url, client_id, client_secret); + match creds.get_token().await { + Ok(token) => println!("{}", token), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + None => { + eprintln!("Error: SSO URL, client ID, and client secret are all required"); + process::exit(1); + } + } + } + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2164b8d78..74fd2746f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,9 +1,11 @@ pub mod sbom; +pub mod auth; use clap::Subcommand; use crate::Context; pub use sbom::SbomCommands; +pub use auth::AuthCommands; #[derive(Subcommand)] pub enum Commands { @@ -12,12 +14,19 @@ pub enum Commands { #[command(subcommand)] command: SbomCommands, }, + + /// Authentication commands + Auth { + #[command(subcommand)] + command: AuthCommands, + }, } impl Commands { pub async fn run(&self, ctx: &Context) { match self { Commands::Sbom { command } => command.run(ctx).await, + Commands::Auth { command } => command.run(ctx).await, } } } diff --git a/src/main.rs b/src/main.rs index 603fe230f..560c2946b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ mod api; -mod auth; mod cli; mod commands; mod config; @@ -8,7 +7,7 @@ use std::process; use clap::Parser; -use api::client::AuthCredentials; +use api::auth::AuthCredentials; use api::ApiClient; use cli::Cli; @@ -28,22 +27,9 @@ async fn main() { // Build auth credentials and get initial token if configured let (token, auth_credentials) = if let Some((sso_url, client_id, client_secret)) = cli.config.auth_credentials() { - let token_url = if sso_url.ends_with("/token") { - sso_url.to_string() - } else if sso_url.ends_with('/') { - format!("{}protocol/openid-connect/token", sso_url) - } else { - format!("{}/protocol/openid-connect/token", sso_url) - }; + let creds = AuthCredentials::new(sso_url, client_id, client_secret); - // Store credentials for token refresh - let creds = AuthCredentials { - token_url: token_url.clone(), - client_id: client_id.to_string(), - client_secret: client_secret.to_string(), - }; - - match auth::get_token(&token_url, client_id, client_secret).await { + match creds.get_token().await { Ok(token) => (Some(token), Some(creds)), Err(e) => { eprintln!("Error: {}", e); From 9b9f851143372b8a66e21fc82506351151e8adf6 Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Thu, 29 Jan 2026 21:04:24 +0800 Subject: [PATCH 05/12] chore: Some minor adjustments. --- Cargo.lock | 16 + Cargo.toml | 4 +- tools/trustify-cli/Cargo.lock | 2070 +++++++++++++++++++++++ tools/trustify-cli/Cargo.toml | 19 + tools/trustify-cli/LICENSE | 190 +++ tools/trustify-cli/README.md | 188 ++ tools/trustify-cli/src/api/auth.rs | 108 ++ tools/trustify-cli/src/api/client.rs | 227 +++ tools/trustify-cli/src/api/mod.rs | 4 + tools/trustify-cli/src/api/sbom.rs | 434 +++++ tools/trustify-cli/src/cli.rs | 17 + tools/trustify-cli/src/commands/auth.rs | 36 + tools/trustify-cli/src/commands/mod.rs | 32 + tools/trustify-cli/src/commands/sbom.rs | 377 +++++ tools/trustify-cli/src/config.rs | 38 + tools/trustify-cli/src/main.rs | 52 + 16 files changed, 3811 insertions(+), 1 deletion(-) create mode 100644 tools/trustify-cli/Cargo.lock create mode 100644 tools/trustify-cli/Cargo.toml create mode 100644 tools/trustify-cli/LICENSE create mode 100644 tools/trustify-cli/README.md create mode 100644 tools/trustify-cli/src/api/auth.rs create mode 100644 tools/trustify-cli/src/api/client.rs create mode 100644 tools/trustify-cli/src/api/mod.rs create mode 100644 tools/trustify-cli/src/api/sbom.rs create mode 100644 tools/trustify-cli/src/cli.rs create mode 100644 tools/trustify-cli/src/commands/auth.rs create mode 100644 tools/trustify-cli/src/commands/mod.rs create mode 100644 tools/trustify-cli/src/commands/sbom.rs create mode 100644 tools/trustify-cli/src/config.rs create mode 100644 tools/trustify-cli/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 236704ff4..bcad75064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5817,6 +5817,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.13", @@ -8109,6 +8110,21 @@ dependencies = [ "utoipa-swagger-ui", ] +[[package]] +name = "trustify-cli" +version = "0.1.0" +dependencies = [ + "clap", + "dotenvy", + "futures", + "indicatif", + "reqwest 0.13.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "trustify-common" version = "0.5.0-alpha.1" diff --git a/Cargo.toml b/Cargo.toml index 1599c7100..137b7e4bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "test-context", "trustd", "xtask", + "tools/trustify-cli" ] [workspace.package] @@ -65,6 +66,7 @@ cve = "0.5.0" cvss = { package = "cvss-rs", version = "0.2.1" } cvss-old = { package = "cvss", version = "2" } deepsize = "0.2.0" +dotenvy = "0.15.7" fixedbitset = "0.5.7" flate2 = "1.0.35" fs4 = "0.13.1" @@ -109,7 +111,7 @@ petgraph = { version = "0.8.0", features = ["serde-1"] } quick-xml = "0.39.0" rand = "0.10.0" regex = "1.10.3" -reqwest = "0.13" +reqwest = "0.13.1" ring = "0.17.8" roxmltree = "0.21.1" rstest = "0.26.1" diff --git a/tools/trustify-cli/Cargo.lock b/tools/trustify-cli/Cargo.lock new file mode 100644 index 000000000..315dd1fb3 --- /dev/null +++ b/tools/trustify-cli/Cargo.lock @@ -0,0 +1,2070 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trustify-cli" +version = "0.1.0" +dependencies = [ + "clap", + "dotenvy", + "futures", + "indicatif", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" diff --git a/tools/trustify-cli/Cargo.toml b/tools/trustify-cli/Cargo.toml new file mode 100644 index 000000000..3e0cbae4d --- /dev/null +++ b/tools/trustify-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "trustify-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "trustify" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true, features = ["derive", "env"] } +dotenvy = { workspace = true } +futures = { workspace = true } +indicatif = { workspace = true } +reqwest = { workspace = true , features = ["blocking", "form", "json", "query"] } +serde = { workspace = true , features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } diff --git a/tools/trustify-cli/LICENSE b/tools/trustify-cli/LICENSE new file mode 100644 index 000000000..b38499aae --- /dev/null +++ b/tools/trustify-cli/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 The Trustify CLI Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/trustify-cli/README.md b/tools/trustify-cli/README.md new file mode 100644 index 000000000..5a6ec0e23 --- /dev/null +++ b/tools/trustify-cli/README.md @@ -0,0 +1,188 @@ +# Trustify CLI + +A command-line tool for interacting with the [Trustify API](https://github.com/guacsec/trustify). Built for DevSecOps teams who need to keep their software supply chain clean and organized. + +## Quick Start + +```bash +# Set up your credentials once +cat > .env << EOF +TRUSTIFY_URL=https://trustify.example.com +TRUSTIFY_SSO_URL=https://sso.example.com/realms/trustify +TRUSTIFY_CLIENT_ID=my-client +TRUSTIFY_CLIENT_SECRET=my-secret +EOF + +# Find all duplicate SBOMs (same document_id, different versions) +trustify sbom duplicates find + +# Preview what would be deleted +trustify sbom duplicates delete --dry-run + +# Clean them up! +trustify sbom duplicates delete +``` + +**Result:** Thousands of duplicate SBOMs cleaned up in seconds with concurrent API requests and automatic retry handling. + +## Features + +- 🔍 **Duplicate detection** — Find and remove duplicate SBOMs by document ID +- 🔐 **Seamless auth** — OAuth2 with automatic token refresh +- 🔄 **Resilient** — Auto-retry on timeouts and transient failures +- 📦 **SBOM management** — List, get, and delete with flexible output formats + +## Index + +- [Installation](#installation) +- [Configuration](#configuration) +- [Commands](#commands) + - [`auth token`](#auth-token) + - [`sbom list`](#sbom-list) + - [`sbom get`](#sbom-get-id) + - [`sbom delete`](#sbom-delete) + - [`sbom duplicates find`](#sbom-duplicates-find) + - [`sbom duplicates delete`](#sbom-duplicates-delete) +- [API Reference](#api-reference) +- [License](#license) + +## Installation + +### From Source + +```bash +# Clone the repository +git clone https://github.com/ruromero/trustify-cli.git +cd trustify-cli + +# Build +cargo build --release + +# The binary will be at ./target/release/trustify +``` + +### Using Docker + +```bash +# Use your .env file with the container +docker run --rm --env-file .env ghcr.io/ruromero/trustify-cli sbom list + +# For commands that write files, mount a volume +docker run --rm --env-file .env -v $(pwd):/data \ + ghcr.io/ruromero/trustify-cli sbom duplicates find --output /data/duplicates.json +``` + +## Configuration + +Create a `.env` file in your working directory: + +```env +TRUSTIFY_URL=https://trustify.example.com +TRUSTIFY_SSO_URL=https://sso.example.com/realms/trustify +TRUSTIFY_CLIENT_ID=my-client +TRUSTIFY_CLIENT_SECRET=my-secret +``` + +That's it! The CLI automatically loads credentials and handles OAuth2 token management. + +> **Tip:** You can also use CLI arguments (`-u`, `--sso-url`, etc.) or shell environment variables. CLI args take priority over env vars, which take priority over `.env` files. + +## Commands + +### Global Options + +``` +-u, --url Trustify API URL (required) + --sso-url SSO URL for authentication + --client-id OAuth2 Client ID + --client-secret OAuth2 Client Secret +-h, --help Print help +-V, --version Print version +``` + +--- + +### `auth token` + +Get an OAuth2 access token for use with other tools. + +```bash +TOKEN=$(trustify auth token) +curl -H "Authorization: Bearer $TOKEN" $TRUSTIFY_URL/api/v2/sbom +``` + +--- + +### `sbom get ` + +Get an SBOM by ID (returns raw JSON). + +```bash +trustify sbom get urn:uuid:abc123 +``` + +--- + +### `sbom list` + +List SBOMs with filtering, pagination, and output formatting. + +```bash +trustify sbom list # Full JSON +trustify sbom list --format id # Just IDs +trustify sbom list --query "name=my-app" # Filter by name +trustify sbom list --limit 10 --offset 20 # Pagination +trustify sbom list --sort "published:desc" # Sort by date +``` + +**Format options:** `id` | `name` | `short` | `full` (default) + +--- + +### `sbom delete` + +Delete an SBOM by ID. + +```bash +trustify sbom delete --id urn:uuid:abc123 +trustify sbom delete --id urn:uuid:abc123 --dry-run # Preview only +``` + +--- + +### `sbom duplicates find` + +Scan all SBOMs and find duplicates by `document_id`. Keeps the most recent version, marks others as duplicates. + +```bash +trustify sbom duplicates find # Default: 4 workers, saves to duplicates.json +trustify sbom duplicates find -j 8 # Faster with 8 concurrent workers +trustify sbom duplicates find -b 500 -j 8 # Larger batches + more workers +trustify sbom duplicates find --output out.json # Custom output file +``` + +**Output file format:** + +```json +[ + { + "document_id": "urn:example:sbom-1.0", + "id": "abc123", // ← Keep this one (most recent) + "published": "2025-01-10T12:00:00Z", + "duplicates": ["def456", "ghi789"] // ← Delete these + } +] +``` + +--- + +### `sbom duplicates delete` + +Delete the duplicates found by `find`. Always preview with `--dry-run` first! + +```bash +trustify sbom duplicates delete --dry-run # Preview what will be deleted +trustify sbom duplicates delete # Delete all duplicates +trustify sbom duplicates delete -j 16 # Faster with 16 concurrent requests +trustify sbom duplicates delete --input out.json # Use custom input file +``` diff --git a/tools/trustify-cli/src/api/auth.rs b/tools/trustify-cli/src/api/auth.rs new file mode 100644 index 000000000..79199ca66 --- /dev/null +++ b/tools/trustify-cli/src/api/auth.rs @@ -0,0 +1,108 @@ +use reqwest::Client; +use serde::Deserialize; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AuthError { + #[error("Failed to connect to SSO server: {0}")] + ConnectionError(#[from] reqwest::Error), + + #[error("Authentication failed: Invalid client_id, client_secret, or SSO URL. Please verify your credentials.")] + AuthenticationFailed, + + #[error("SSO server returned an error: {0}")] + ServerError(String), +} + +/// Authentication credentials for token refresh +#[derive(Clone)] +pub struct AuthCredentials { + pub token_url: String, + pub client_id: String, + pub client_secret: String, +} + +impl AuthCredentials { + /// Build credentials from SSO URL and client credentials + pub fn new(sso_url: &str, client_id: &str, client_secret: &str) -> Self { + let token_url = build_token_url(sso_url); + Self { + token_url, + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + } + } + + /// Get a token using these credentials + pub async fn get_token(&self) -> Result { + get_token(&self.token_url, &self.client_id, &self.client_secret).await + } +} + +/// Build the token URL from an SSO base URL +pub fn build_token_url(sso_url: &str) -> String { + if sso_url.ends_with("/token") { + sso_url.to_string() + } else if sso_url.ends_with('/') { + format!("{}protocol/openid-connect/token", sso_url) + } else { + format!("{}/protocol/openid-connect/token", sso_url) + } +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + #[allow(dead_code)] + token_type: String, + #[allow(dead_code)] + expires_in: Option, +} + +#[derive(Deserialize)] +struct ErrorResponse { + error: String, + error_description: Option, +} + +/// Retrieves an OAuth2 access token using client credentials grant +pub async fn get_token( + token_url: &str, + client_id: &str, + client_secret: &str, +) -> Result { + let client = Client::new(); + + let response = client + .post(token_url) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", client_id), + ("client_secret", client_secret), + ]) + .send() + .await?; + + if response.status().is_success() { + let token_response: TokenResponse = response.json().await?; + Ok(token_response.access_token) + } else if response.status().as_u16() == 401 || response.status().as_u16() == 400 { + // Try to get error details + if let Ok(error_response) = response.json::().await { + if error_response.error == "invalid_client" + || error_response.error == "unauthorized_client" + { + return Err(AuthError::AuthenticationFailed); + } + let msg = error_response + .error_description + .unwrap_or(error_response.error); + return Err(AuthError::ServerError(msg)); + } + Err(AuthError::AuthenticationFailed) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(AuthError::ServerError(format!("HTTP {}: {}", status, body))) + } +} diff --git a/tools/trustify-cli/src/api/client.rs b/tools/trustify-cli/src/api/client.rs new file mode 100644 index 000000000..d06704d0f --- /dev/null +++ b/tools/trustify-cli/src/api/client.rs @@ -0,0 +1,227 @@ +use std::sync::Arc; +use std::time::Duration; + +use reqwest::{Client, RequestBuilder, StatusCode}; +use thiserror::Error; +use tokio::sync::RwLock; +use tokio::time::sleep; + + +const MAX_RETRIES: u32 = 3; +const RETRY_DELAY_MS: u64 = 1000; + +#[derive(Error, Debug, Clone)] +pub enum ApiError { + #[error("Network error: {0}")] + NetworkError(String), + + #[error("HTTP {0}: {1}")] + HttpError(u16, String), + + #[error("HTTP 404: Resource not found")] + NotFound(String), + + #[error("HTTP 401: Please check your authentication credentials")] + Unauthorized, + + #[error("HTTP 401: Token expired")] + TokenExpired, + + #[error("HTTP {0}: Server timeout")] + Timeout(u16), + + #[error("HTTP {0}: {1}")] + ServerError(u16, String), + + #[error("{0}")] + InternalError(String), +} + +impl From for ApiError { + fn from(e: reqwest::Error) -> Self { + if e.is_timeout() { + ApiError::Timeout(0) // 0 indicates network-level timeout (no HTTP response) + } else if e.is_connect() { + ApiError::NetworkError(format!("Connection failed: {}", e)) + } else if e.is_request() { + ApiError::NetworkError(format!("Request error: {}", e)) + } else { + ApiError::NetworkError(e.to_string()) + } + } +} + +// Re-export AuthCredentials from auth module +pub use super::auth::AuthCredentials; + +/// API client for Trustify with retry and token refresh support +#[derive(Clone)] +pub struct ApiClient { + client: Client, + base_url: String, + token: Arc>>, + auth_credentials: Option, +} + +impl ApiClient { + pub fn new( + base_url: &str, + token: Option, + auth_credentials: Option, + ) -> Self { + let base_url = base_url.trim_end_matches('/').to_string(); + + Self { + client: Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .unwrap_or_else(|_| Client::new()), + base_url, + token: Arc::new(RwLock::new(token)), + auth_credentials, + } + } + + /// Build the full API URL + pub fn url(&self, path: &str) -> String { + format!("{}/api{}", self.base_url, path) + } + + /// Add authorization header if token is present + async fn authorize(&self, request: RequestBuilder) -> RequestBuilder { + let token = self.token.read().await; + match &*token { + Some(t) => request.bearer_auth(t), + None => request, + } + } + + /// Refresh the token using stored credentials + async fn refresh_token(&self) -> Result<(), ApiError> { + let creds = self + .auth_credentials + .as_ref() + .ok_or(ApiError::Unauthorized)?; + + eprintln!("Token expired, refreshing..."); + + match creds.get_token().await { + Ok(new_token) => { + let mut token = self.token.write().await; + *token = Some(new_token); + eprintln!("Token refreshed successfully"); + Ok(()) + } + Err(e) => { + eprintln!("Failed to refresh token: {}", e); + Err(ApiError::Unauthorized) + } + } + } + + /// Perform a GET request with retry logic + pub async fn get(&self, path: &str) -> Result { + self.execute_with_retry(|| async { + let url = self.url(path); + let request = self.client.get(&url); + let response = self.authorize(request).await.send().await?; + self.handle_response(response).await + }) + .await + } + + /// Perform a GET request with query parameters and retry logic + pub async fn get_with_query( + &self, + path: &str, + query: &T, + ) -> Result { + self.execute_with_retry(|| async { + let url = self.url(path); + let request = self.client.get(&url).query(query); + let response = self.authorize(request).await.send().await?; + self.handle_response(response).await + }) + .await + } + + /// Perform a DELETE request with retry logic + pub async fn delete(&self, path: &str) -> Result { + self.execute_with_retry(|| async { + let url = self.url(path); + let request = self.client.delete(&url); + let response = self.authorize(request).await.send().await?; + self.handle_response(response).await + }) + .await + } + + /// Execute a request with retry logic for timeouts and token refresh + async fn execute_with_retry(&self, f: F) -> Result + where + F: Fn() -> Fut, + Fut: std::future::Future>, + { + let mut last_error = ApiError::NetworkError("No attempts made".to_string()); + let mut token_refreshed = false; + + for attempt in 0..MAX_RETRIES { + match f().await { + Ok(result) => return Ok(result), + Err(ApiError::TokenExpired) => { + if !token_refreshed + && self.auth_credentials.is_some() + && self.refresh_token().await.is_ok() + { + token_refreshed = true; + continue; // Retry with new token + } + return Err(ApiError::Unauthorized); + } + Err(ref e @ ApiError::Timeout(_)) + | Err(ref e @ ApiError::ServerError(_, _)) + | Err(ref e @ ApiError::NetworkError(_)) + if attempt < MAX_RETRIES - 1 => + { + let delay = RETRY_DELAY_MS * (attempt as u64 + 1); + eprintln!( + "{}, retrying in {}ms... (attempt {}/{})", + e, + delay, + attempt + 1, + MAX_RETRIES + ); + sleep(Duration::from_millis(delay)).await; + last_error = e.clone(); + } + Err(e) => return Err(e), + } + } + + Err(last_error) + } + + async fn handle_response(&self, response: reqwest::Response) -> Result { + let status = response.status(); + let status_code = status.as_u16(); + + if status.is_success() { + Ok(response.text().await?) + } else if status == StatusCode::NOT_FOUND { + Err(ApiError::NotFound("Resource not found".to_string())) + } else if status == StatusCode::UNAUTHORIZED { + Err(ApiError::TokenExpired) + } else if status == StatusCode::FORBIDDEN { + Err(ApiError::Unauthorized) + } else if status == StatusCode::GATEWAY_TIMEOUT || status == StatusCode::REQUEST_TIMEOUT { + Err(ApiError::Timeout(status_code)) + } else { + let body = response.text().await.unwrap_or_default(); + if status.is_server_error() { + Err(ApiError::ServerError(status_code, body)) + } else { + Err(ApiError::HttpError(status_code, body)) + } + } + } +} diff --git a/tools/trustify-cli/src/api/mod.rs b/tools/trustify-cli/src/api/mod.rs new file mode 100644 index 000000000..666dc0fd0 --- /dev/null +++ b/tools/trustify-cli/src/api/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod sbom; +pub mod auth; +pub use client::ApiClient; diff --git a/tools/trustify-cli/src/api/sbom.rs b/tools/trustify-cli/src/api/sbom.rs new file mode 100644 index 000000000..57d490543 --- /dev/null +++ b/tools/trustify-cli/src/api/sbom.rs @@ -0,0 +1,434 @@ +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use futures::future::join_all; +use futures::stream::{self, StreamExt}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::Mutex; + +use super::client::{ApiClient, ApiError}; + +const SBOM_PATH: &str = "/v2/sbom"; + +/// Query parameters for listing SBOMs +#[derive(Default, Serialize)] +pub struct ListParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub q: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, +} + +/// Parameters for find duplicates +pub struct FindDuplicatesParams { + pub batch_size: u32, + pub concurrency: usize, +} + +/// SBOM entry for duplicate detection +#[derive(Debug, Clone)] +struct SbomEntry { + id: String, + document_id: String, + published: Option, +} + +/// Duplicate group output format +#[derive(Debug, Serialize, Deserialize)] +pub struct DuplicateGroup { + pub document_id: String, + pub published: Option, + pub id: String, + pub duplicates: Vec, +} + +/// Get SBOM by ID - returns raw JSON +pub async fn get(client: &ApiClient, id: &str) -> Result { + let path = format!("{}/{}", SBOM_PATH, id); + client.get(&path).await +} + +/// List SBOMs with optional query parameters - returns raw JSON +pub async fn list(client: &ApiClient, params: &ListParams) -> Result { + client.get_with_query(SBOM_PATH, params).await +} + +/// Fetch a single page and extract SBOM entries +async fn fetch_page( + client: &ApiClient, + batch_size: u32, + offset: u32, +) -> Result, ApiError> { + let list_params = ListParams { + q: None, + limit: Some(batch_size), + offset: Some(offset), + sort: None, + }; + + let response = list(client, &list_params).await?; + let parsed: Value = serde_json::from_str(&response) + .map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?; + + let items = parsed + .get("items") + .and_then(|v| v.as_array()) + .ok_or_else(|| ApiError::InternalError("No items in response".to_string()))?; + + let entries: Vec = items + .iter() + .filter_map(|item| { + let id = item.get("id").and_then(|v| v.as_str())?.to_string(); + let document_id = item + .get("document_id") + .and_then(|v| v.as_str())? + .to_string(); + let published = item + .get("published") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if document_id.is_empty() { + None + } else { + Some(SbomEntry { + id, + document_id, + published, + }) + } + }) + .collect(); + + Ok(entries) +} + +/// Worker that fetches assigned pages sequentially +async fn fetch_worker( + worker_id: usize, + client: ApiClient, + pages: Vec, + batch_size: u32, + progress_bar: ProgressBar, + results: Arc>>, +) { + let mut fetched: u64 = 0; + + for offset in pages { + match fetch_page(&client, batch_size, offset).await { + Ok(entries) => { + fetched += entries.len() as u64; + progress_bar.set_position(fetched); + results.lock().await.extend(entries); + } + Err(e) => { + progress_bar.println(format!( + "Worker {}: Error at offset {}: {}", + worker_id, offset, e + )); + } + } + } + + progress_bar.finish_with_message("done"); +} + +/// Find duplicate SBOMs by document_id and save to file +pub async fn find_duplicates( + client: &ApiClient, + params: &FindDuplicatesParams, + output_file: &Option, +) -> Result, ApiError> { + let batch_size = params.batch_size; + let concurrency = params.concurrency; + + // First, get the total count + let first_page = list( + client, + &ListParams { + q: None, + limit: Some(1), + offset: Some(0), + sort: None, + }, + ) + .await?; + + let parsed: Value = serde_json::from_str(&first_page) + .map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?; + + let total = parsed.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + + if total == 0 { + eprintln!("No SBOMs found"); + return Ok(Vec::new()); + } + + eprintln!("Fetching {} SBOMs with {} workers...\n", total, concurrency); + + // Calculate page offsets + let num_pages = total.div_ceil(batch_size); + let all_offsets: Vec = (0..num_pages).map(|i| i * batch_size).collect(); + + // Distribute pages evenly among workers + let mut worker_pages: Vec> = vec![Vec::new(); concurrency]; + for (i, offset) in all_offsets.into_iter().enumerate() { + worker_pages[i % concurrency].push(offset); + } + + // Calculate how many SBOMs each worker will fetch + let worker_counts: Vec = worker_pages + .iter() + .map(|pages| { + pages + .iter() + .map(|&offset| { + let remaining = total.saturating_sub(offset); + remaining.min(batch_size) as u64 + }) + .sum() + }) + .collect(); + + // Set up progress bars + let multi_progress = MultiProgress::new(); + let style = ProgressStyle::default_bar() + .template("{prefix:>12} [{bar:30.cyan/blue}] {pos}/{len} ({percent}%)") + .unwrap() + .progress_chars("█▓░"); + + let results: Arc>> = Arc::new(Mutex::new(Vec::new())); + + // Spawn workers + let mut handles = Vec::new(); + for (worker_id, pages) in worker_pages.into_iter().enumerate() { + if pages.is_empty() { + continue; + } + + let worker_total = worker_counts[worker_id]; + let pb = multi_progress.add(ProgressBar::new(worker_total)); + pb.set_style(style.clone()); + pb.set_prefix(format!("Worker {}", worker_id + 1)); + + let client = client.clone(); + let results = Arc::clone(&results); + + handles.push(tokio::spawn(fetch_worker( + worker_id + 1, + client, + pages, + batch_size, + pb, + results, + ))); + } + + // Wait for all workers to complete + join_all(handles).await; + + let all_entries = Arc::try_unwrap(results) + .map_err(|_| ApiError::InternalError("Failed to unwrap entries".to_string()))? + .into_inner(); + + eprintln!("\nProcessing {} SBOMs for duplicates...", all_entries.len()); + + // Group by document_id + let mut groups: HashMap> = HashMap::new(); + for entry in all_entries { + groups + .entry(entry.document_id.clone()) + .or_default() + .push(entry); + } + + // Find duplicates (groups with more than one entry) + let mut duplicate_groups: Vec = Vec::new(); + + for (document_id, mut entries) in groups { + if entries.len() <= 1 { + continue; + } + + // Sort by published date descending (most recent first) + entries.sort_by(|a, b| match (&b.published, &a.published) { + (Some(b_pub), Some(a_pub)) => b_pub.cmp(a_pub), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + + let most_recent = entries.remove(0); + let duplicates: Vec = entries.into_iter().map(|e| e.id).collect(); + + duplicate_groups.push(DuplicateGroup { + document_id, + published: most_recent.published, + id: most_recent.id, + duplicates, + }); + } + + eprintln!( + "Found {} document(s) with duplicates", + duplicate_groups.len() + ); + + // Save to file + let output_path = output_file + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("duplicates.json"); + + let json = serde_json::to_string_pretty(&duplicate_groups) + .map_err(|e| ApiError::InternalError(format!("Failed to serialize results: {}", e)))?; + + let mut file = File::create(output_path) + .map_err(|e| ApiError::InternalError(format!("Failed to create output file: {}", e)))?; + + file.write_all(json.as_bytes()) + .map_err(|e| ApiError::InternalError(format!("Failed to write to file: {}", e)))?; + + Ok(duplicate_groups) +} + +/// Delete an SBOM by ID +pub async fn delete(client: &ApiClient, id: &str) -> Result<(), ApiError> { + let path = format!("{}/{}", SBOM_PATH, id); + client.delete(&path).await?; + Ok(()) +} + +/// Result of deleting duplicates +pub struct DeleteDuplicatesResult { + pub deleted: u32, + pub skipped: u32, + pub failed: u32, + pub total: u32, +} + +/// Entry to delete with its document_id for logging +#[derive(Clone)] +struct DeleteEntry { + id: String, + document_id: String, +} + +/// Delete duplicates from a file with progress bar +pub async fn delete_duplicates( + client: &ApiClient, + input_file: &str, + concurrency: usize, + dry_run: bool, +) -> Result { + // Check if file exists + let path = Path::new(input_file); + if !path.exists() { + return Err(ApiError::InternalError(format!( + "Input file not found: {}", + input_file + ))); + } + + // Read and parse the file + let file = File::open(path) + .map_err(|e| ApiError::InternalError(format!("Failed to open input file: {}", e)))?; + let reader = BufReader::new(file); + + let groups: Vec = serde_json::from_reader(reader) + .map_err(|e| ApiError::InternalError(format!("Failed to parse input file: {}", e)))?; + + // Collect all duplicate entries to delete + let entries: Vec = groups + .iter() + .flat_map(|group| { + group.duplicates.iter().map(|id| DeleteEntry { + id: id.clone(), + document_id: group.document_id.clone(), + }) + }) + .collect(); + + let total = entries.len() as u32; + + if dry_run { + for entry in &entries { + eprintln!( + "[DRY-RUN] Would delete: {} (document_id: {})", + entry.id, entry.document_id + ); + } + return Ok(DeleteDuplicatesResult { + deleted: 0, + skipped: 0, + failed: 0, + total, + }); + } + + eprintln!( + "Deleting {} duplicates with {} concurrent requests...\n", + total, concurrency + ); + + // Set up progress bar + let progress = ProgressBar::new(total as u64); + progress.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}") + .unwrap() + .progress_chars("█▓░"), + ); + + let deleted = Arc::new(AtomicU32::new(0)); + let skipped = Arc::new(AtomicU32::new(0)); + let failed = Arc::new(AtomicU32::new(0)); + + stream::iter(entries) + .for_each_concurrent(concurrency, |entry| { + let client = client.clone(); + let deleted = Arc::clone(&deleted); + let skipped = Arc::clone(&skipped); + let failed = Arc::clone(&failed); + let progress = progress.clone(); + async move { + match delete(&client, &entry.id).await { + Ok(_) => { + deleted.fetch_add(1, Ordering::Relaxed); + } + Err(ApiError::NotFound(_)) => { + // SBOM already deleted or doesn't exist - skip silently + skipped.fetch_add(1, Ordering::Relaxed); + } + Err(e) => { + failed.fetch_add(1, Ordering::Relaxed); + progress.println(format!( + "Failed to delete {} (document_id: {}): {}", + entry.id, entry.document_id, e + )); + } + } + progress.inc(1); + } + }) + .await; + + progress.finish_with_message("complete"); + + Ok(DeleteDuplicatesResult { + deleted: deleted.load(Ordering::Relaxed), + skipped: skipped.load(Ordering::Relaxed), + failed: failed.load(Ordering::Relaxed), + total, + }) +} diff --git a/tools/trustify-cli/src/cli.rs b/tools/trustify-cli/src/cli.rs new file mode 100644 index 000000000..fbcae8ae8 --- /dev/null +++ b/tools/trustify-cli/src/cli.rs @@ -0,0 +1,17 @@ +use clap::Parser; + +use crate::commands::Commands; +use crate::config::Config; + +/// Trustify CLI - Software Supply-Chain Security tool +#[derive(Parser)] +#[command(name = "trustify")] +#[command(about = "CLI for interacting with the Trustify API", long_about = None)] +#[command(version)] +pub struct Cli { + #[command(flatten)] + pub config: Config, + + #[command(subcommand)] + pub command: Commands, +} diff --git a/tools/trustify-cli/src/commands/auth.rs b/tools/trustify-cli/src/commands/auth.rs new file mode 100644 index 000000000..7ca608fb5 --- /dev/null +++ b/tools/trustify-cli/src/commands/auth.rs @@ -0,0 +1,36 @@ +use clap::Subcommand; + +use std::process; +use crate::Context; +use crate::api::auth::AuthCredentials; + +#[derive(Subcommand)] +pub enum AuthCommands { + /// Get authentication token + Token {}, +} + +impl AuthCommands { + pub async fn run(&self, ctx: &Context) { + match self { + AuthCommands::Token {} => { + match ctx.config.auth_credentials() { + Some((token_url, client_id, client_secret)) => { + let creds = AuthCredentials::new(token_url, client_id, client_secret); + match creds.get_token().await { + Ok(token) => println!("{}", token), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + None => { + eprintln!("Error: SSO URL, client ID, and client secret are all required"); + process::exit(1); + } + } + } + } + } +} diff --git a/tools/trustify-cli/src/commands/mod.rs b/tools/trustify-cli/src/commands/mod.rs new file mode 100644 index 000000000..74fd2746f --- /dev/null +++ b/tools/trustify-cli/src/commands/mod.rs @@ -0,0 +1,32 @@ +pub mod sbom; +pub mod auth; + +use clap::Subcommand; + +use crate::Context; +pub use sbom::SbomCommands; +pub use auth::AuthCommands; + +#[derive(Subcommand)] +pub enum Commands { + /// SBOM management commands + Sbom { + #[command(subcommand)] + command: SbomCommands, + }, + + /// Authentication commands + Auth { + #[command(subcommand)] + command: AuthCommands, + }, +} + +impl Commands { + pub async fn run(&self, ctx: &Context) { + match self { + Commands::Sbom { command } => command.run(ctx).await, + Commands::Auth { command } => command.run(ctx).await, + } + } +} diff --git a/tools/trustify-cli/src/commands/sbom.rs b/tools/trustify-cli/src/commands/sbom.rs new file mode 100644 index 000000000..cb79d51d9 --- /dev/null +++ b/tools/trustify-cli/src/commands/sbom.rs @@ -0,0 +1,377 @@ +use std::io::{self, Write}; +use std::path::Path; +use std::process; + +use clap::{Subcommand, ValueEnum}; +use serde_json::Value; + +use crate::api::sbom as sbom_api; +use crate::Context; + +/// Output format for SBOM list +#[derive(Clone, Default, ValueEnum)] +pub enum ListFormat { + /// Only output the SBOM ID + Id, + /// Output id, name, document_id + Name, + /// Output id, name, document_id, ingested, published, size + Short, + /// Output complete JSON document + #[default] + Full, +} + +#[derive(Subcommand)] +pub enum DuplicatesCommands { + /// Find duplicates by namespace + Find { + /// Batch size for querying duplicates + #[arg(short = 'b', long, default_value = "100")] + batch_size: u32, + + /// Number of concurrent fetch requests (default: 4) + #[arg(short = 'j', long, default_value = "4")] + concurrency: usize, + + /// Output file + #[arg(long, default_value = "duplicates.json")] + output: Option, + }, + /// Delete duplicates + Delete { + /// Input file + #[arg(long, default_value = "duplicates.json")] + input: Option, + + /// Number of concurrent delete requests (default: 8) + #[arg(short = 'j', long, default_value = "8")] + concurrency: usize, + + /// Perform a dry run without actually deleting + #[arg(long)] + dry_run: bool, + }, +} + +#[derive(Subcommand)] +pub enum SbomCommands { + /// Get SBOM by ID + Get { + /// SBOM ID + id: String, + }, + /// List SBOMs + List { + /// Query filter for SBOMs + #[arg(long)] + query: Option, + /// Limit the number of results + #[arg(long)] + limit: Option, + /// Offset the results + #[arg(long)] + offset: Option, + /// Sort the results + /// Example: `purl:qualifiers:type:desc` + #[arg(long)] + sort: Option, + /// Output format: id, name, short, full (default: full) + #[arg(long, value_enum, default_value = "full")] + format: ListFormat, + }, + /// Delete SBOMs + Delete { + /// SBOM ID + #[arg(long)] + id: Option, + + /// Query filter for SBOMs to delete + #[arg(long)] + query: Option, + + /// Perform a dry run without actually deleting + #[arg(long)] + dry_run: bool, + }, + /// Manage duplicate SBOMs + Duplicates { + #[command(subcommand)] + command: DuplicatesCommands, + }, +} + +impl SbomCommands { + pub async fn run(&self, ctx: &Context) { + match self { + SbomCommands::Duplicates { command } => { + command.run(ctx).await; + } + SbomCommands::Get { id } => match sbom_api::get(&ctx.client, id).await { + Ok(json) => println!("{}", json), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + }, + SbomCommands::List { + query, + limit, + offset, + sort, + format, + } => { + let params = sbom_api::ListParams { + q: query.clone(), + limit: *limit, + offset: *offset, + sort: sort.clone(), + }; + match sbom_api::list(&ctx.client, ¶ms).await { + Ok(json) => { + format_list_output(&json, format); + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + SbomCommands::Delete { id, query, dry_run } => { + println!( + "SBOM delete command executed successfully!{}", + if *dry_run { " (dry-run)" } else { "" } + ); + if let Some(i) = id { + println!(" ID: {}", i); + } + if let Some(q) = query { + println!(" Query: {}", q); + } + } + } + } +} + +impl DuplicatesCommands { + pub async fn run(&self, ctx: &Context) { + match self { + DuplicatesCommands::Find { + batch_size, + concurrency, + output, + } => { + let output_path = output + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("duplicates.json"); + + // Check if output file exists + let final_output = check_output_file(output_path); + if final_output.is_none() { + eprintln!("Operation cancelled."); + process::exit(0); + } + let final_output = final_output.unwrap(); + + let params = sbom_api::FindDuplicatesParams { + batch_size: *batch_size, + concurrency: *concurrency, + }; + match sbom_api::find_duplicates(&ctx.client, ¶ms, &Some(final_output.clone())) + .await + { + Ok(groups) => { + let total_duplicates: usize = + groups.iter().map(|g| g.duplicates.len()).sum(); + println!( + "Found {} document(s) with {} duplicate(s). Saved to {}", + groups.len(), + total_duplicates, + final_output + ); + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + DuplicatesCommands::Delete { + input, + concurrency, + dry_run, + } => { + let input_path = input + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("duplicates.json"); + + match sbom_api::delete_duplicates(&ctx.client, input_path, *concurrency, *dry_run) + .await + { + Ok(result) => { + if *dry_run { + println!("[DRY-RUN] Would delete {} duplicate(s)", result.total); + } else { + let mut msg = format!("Deleted {} duplicate(s)", result.deleted); + if result.skipped > 0 { + msg.push_str(&format!(", {} skipped (not found)", result.skipped)); + } + if result.failed > 0 { + msg.push_str(&format!(", {} failed", result.failed)); + } + msg.push_str(&format!(" out of {} total", result.total)); + println!("{}", msg); + } + } + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } + } + } +} + +/// Format and print list output based on the specified format +fn format_list_output(json: &str, format: &ListFormat) { + let parsed: Value = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + eprintln!("Error parsing response: {}", e); + process::exit(1); + } + }; + + // The API returns { "items": [...], "total": N } + let items = match parsed.get("items").and_then(|v| v.as_array()) { + Some(arr) => arr, + None => { + // If no items array, just print the raw JSON + println!("{}", json); + return; + } + }; + + match format { + ListFormat::Full => { + println!("{}", json); + } + ListFormat::Id => { + for item in items { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + println!("{}", id); + } + } + } + ListFormat::Name => { + let result: Vec = items + .iter() + .map(|item| { + serde_json::json!({ + "id": item.get("id"), + "name": item.get("name"), + "document_id": item.get("document_id") + }) + }) + .collect(); + println!("{}", serde_json::to_string(&result).unwrap_or_default()); + } + ListFormat::Short => { + let result: Vec = items + .iter() + .map(|item| { + serde_json::json!({ + "id": item.get("id"), + "name": item.get("name"), + "document_id": item.get("document_id"), + "ingested": item.get("ingested"), + "published": item.get("published"), + "size": item.get("size"), + }) + }) + .collect(); + println!("{}", serde_json::to_string(&result).unwrap_or_default()); + } + } +} + +/// Check if output file exists and prompt user for action +/// Returns None if user cancels, Some(path) with the final path to use +fn check_output_file(output_path: &str) -> Option { + let path = Path::new(output_path); + + if !path.exists() { + return Some(output_path.to_string()); + } + + // File exists, ask user what to do + loop { + eprint!( + "File '{}' already exists. Overwrite? [y]es / [n]o / [r]ename: ", + output_path + ); + io::stderr().flush().ok(); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_err() { + return None; + } + + let input = input.trim().to_lowercase(); + match input.as_str() { + "y" | "yes" => { + return Some(output_path.to_string()); + } + "n" | "no" => { + return None; + } + "r" | "rename" => { + // Generate a new filename + let new_name = generate_unique_filename(output_path); + eprintln!("Using: {}", new_name); + return Some(new_name); + } + _ => { + eprintln!("Please enter 'y', 'n', or 'r'"); + } + } + } +} + +/// Generate a unique filename by appending a number +fn generate_unique_filename(base_path: &str) -> String { + let path = Path::new(base_path); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("duplicates"); + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("json"); + let parent = path.parent().and_then(|p| p.to_str()).unwrap_or(""); + + for i in 1..1000 { + let new_name = if parent.is_empty() { + format!("{}_{}.{}", stem, i, ext) + } else { + format!("{}/{}_{}.{}", parent, stem, i, ext) + }; + + if !Path::new(&new_name).exists() { + return new_name; + } + } + + // Fallback with timestamp + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if parent.is_empty() { + format!("{}_{}.{}", stem, timestamp, ext) + } else { + format!("{}/{}_{}.{}", parent, stem, timestamp, ext) + } +} diff --git a/tools/trustify-cli/src/config.rs b/tools/trustify-cli/src/config.rs new file mode 100644 index 000000000..b11e299a8 --- /dev/null +++ b/tools/trustify-cli/src/config.rs @@ -0,0 +1,38 @@ +use clap::Args; + +/// Configuration for connecting to Trustify API +#[derive(Args, Clone)] +pub struct Config { + /// Trustify API URL (required) + #[arg(short = 'u', long = "url", env = "TRUSTIFY_URL")] + pub url: String, + + /// SSO URL for authentication + #[arg(long = "sso-url", env = "TRUSTIFY_SSO_URL")] + pub sso_url: Option, + + /// OAuth2 Client ID + #[arg(long = "client-id", env = "TRUSTIFY_CLIENT_ID")] + pub client_id: Option, + + /// OAuth2 Client Secret + #[arg(long = "client-secret", env = "TRUSTIFY_CLIENT_SECRET")] + pub client_secret: Option, +} + +impl Config { + /// Returns true if authentication credentials are configured + pub fn has_auth(&self) -> bool { + self.sso_url.is_some() && self.client_id.is_some() && self.client_secret.is_some() + } + + /// Returns the auth credentials if all are present + pub fn auth_credentials(&self) -> Option<(&str, &str, &str)> { + match (&self.sso_url, &self.client_id, &self.client_secret) { + (Some(sso), Some(id), Some(secret)) => { + Some((sso.as_str(), id.as_str(), secret.as_str())) + } + _ => None, + } + } +} diff --git a/tools/trustify-cli/src/main.rs b/tools/trustify-cli/src/main.rs new file mode 100644 index 000000000..560c2946b --- /dev/null +++ b/tools/trustify-cli/src/main.rs @@ -0,0 +1,52 @@ +mod api; +mod cli; +mod commands; +mod config; + +use std::process; + +use clap::Parser; + +use api::auth::AuthCredentials; +use api::ApiClient; +use cli::Cli; + +/// Runtime context containing config and API client +pub struct Context { + pub config: config::Config, + pub client: ApiClient, +} + +#[tokio::main] +async fn main() { + // Load .env file if present (silently ignore if not found) + let _ = dotenvy::dotenv(); + + let cli = Cli::parse(); + + // Build auth credentials and get initial token if configured + let (token, auth_credentials) = + if let Some((sso_url, client_id, client_secret)) = cli.config.auth_credentials() { + let creds = AuthCredentials::new(sso_url, client_id, client_secret); + + match creds.get_token().await { + Ok(token) => (Some(token), Some(creds)), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } else { + (None, None) + }; + + // Create API client with auth credentials for token refresh + let client = ApiClient::new(&cli.config.url, token, auth_credentials); + + let ctx = Context { + config: cli.config, + client, + }; + + cli.command.run(&ctx).await; +} From 7a55edd6bf57e263c96a082deac03dd0395f0948 Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Thu, 29 Jan 2026 23:14:09 +0800 Subject: [PATCH 06/12] chore: Downgrade reqwest to 0.12, remove the form and query features, format the code, and replace unwrap to make clippy happy. --- Cargo.toml | 2 +- tools/trustify-cli/Cargo.toml | 2 +- tools/trustify-cli/src/api/auth.rs | 4 +++- tools/trustify-cli/src/api/client.rs | 11 ++++++++- tools/trustify-cli/src/api/mod.rs | 2 +- tools/trustify-cli/src/api/sbom.rs | 8 +++---- tools/trustify-cli/src/commands/auth.rs | 30 ++++++++++++------------- tools/trustify-cli/src/commands/mod.rs | 4 ++-- tools/trustify-cli/src/commands/sbom.rs | 4 ++-- tools/trustify-cli/src/main.rs | 2 +- 10 files changed, 38 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 137b7e4bf..d718c66cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,7 +111,7 @@ petgraph = { version = "0.8.0", features = ["serde-1"] } quick-xml = "0.39.0" rand = "0.10.0" regex = "1.10.3" -reqwest = "0.13.1" +reqwest = { version = "0.13.1", features = ["blocking", "form", "json", "query"] } ring = "0.17.8" roxmltree = "0.21.1" rstest = "0.26.1" diff --git a/tools/trustify-cli/Cargo.toml b/tools/trustify-cli/Cargo.toml index 3e0cbae4d..22c64b1f2 100644 --- a/tools/trustify-cli/Cargo.toml +++ b/tools/trustify-cli/Cargo.toml @@ -12,7 +12,7 @@ clap = { workspace = true, features = ["derive", "env"] } dotenvy = { workspace = true } futures = { workspace = true } indicatif = { workspace = true } -reqwest = { workspace = true , features = ["blocking", "form", "json", "query"] } +reqwest = { workspace = true, features = ["blocking", "json"] } serde = { workspace = true , features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/tools/trustify-cli/src/api/auth.rs b/tools/trustify-cli/src/api/auth.rs index 79199ca66..1fb6bf0b6 100644 --- a/tools/trustify-cli/src/api/auth.rs +++ b/tools/trustify-cli/src/api/auth.rs @@ -7,7 +7,9 @@ pub enum AuthError { #[error("Failed to connect to SSO server: {0}")] ConnectionError(#[from] reqwest::Error), - #[error("Authentication failed: Invalid client_id, client_secret, or SSO URL. Please verify your credentials.")] + #[error( + "Authentication failed: Invalid client_id, client_secret, or SSO URL. Please verify your credentials." + )] AuthenticationFailed, #[error("SSO server returned an error: {0}")] diff --git a/tools/trustify-cli/src/api/client.rs b/tools/trustify-cli/src/api/client.rs index d06704d0f..ba52e0b28 100644 --- a/tools/trustify-cli/src/api/client.rs +++ b/tools/trustify-cli/src/api/client.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use std::time::Duration; +use indicatif::style::TemplateError; use reqwest::{Client, RequestBuilder, StatusCode}; use thiserror::Error; use tokio::sync::RwLock; use tokio::time::sleep; - const MAX_RETRIES: u32 = 3; const RETRY_DELAY_MS: u64 = 1000; @@ -35,6 +35,9 @@ pub enum ApiError { #[error("{0}")] InternalError(String), + + #[error("Template error: {0}")] + TemplateError(String), } impl From for ApiError { @@ -51,6 +54,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(e: TemplateError) -> Self { + ApiError::TemplateError(e.to_string()) + } +} + // Re-export AuthCredentials from auth module pub use super::auth::AuthCredentials; diff --git a/tools/trustify-cli/src/api/mod.rs b/tools/trustify-cli/src/api/mod.rs index 666dc0fd0..0cb893a05 100644 --- a/tools/trustify-cli/src/api/mod.rs +++ b/tools/trustify-cli/src/api/mod.rs @@ -1,4 +1,4 @@ +pub mod auth; pub mod client; pub mod sbom; -pub mod auth; pub use client::ApiClient; diff --git a/tools/trustify-cli/src/api/sbom.rs b/tools/trustify-cli/src/api/sbom.rs index 57d490543..0a9641055 100644 --- a/tools/trustify-cli/src/api/sbom.rs +++ b/tools/trustify-cli/src/api/sbom.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::fs::File; use std::io::{BufReader, Write}; use std::path::Path; -use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; use futures::future::join_all; use futures::stream::{self, StreamExt}; @@ -203,8 +203,7 @@ pub async fn find_duplicates( // Set up progress bars let multi_progress = MultiProgress::new(); let style = ProgressStyle::default_bar() - .template("{prefix:>12} [{bar:30.cyan/blue}] {pos}/{len} ({percent}%)") - .unwrap() + .template("{prefix:>12} [{bar:30.cyan/blue}] {pos}/{len} ({percent}%)")? .progress_chars("█▓░"); let results: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -385,8 +384,7 @@ pub async fn delete_duplicates( let progress = ProgressBar::new(total as u64); progress.set_style( ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}") - .unwrap() + .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}")? .progress_chars("█▓░"), ); diff --git a/tools/trustify-cli/src/commands/auth.rs b/tools/trustify-cli/src/commands/auth.rs index 7ca608fb5..a9eb68002 100644 --- a/tools/trustify-cli/src/commands/auth.rs +++ b/tools/trustify-cli/src/commands/auth.rs @@ -1,8 +1,8 @@ use clap::Subcommand; -use std::process; use crate::Context; use crate::api::auth::AuthCredentials; +use std::process; #[derive(Subcommand)] pub enum AuthCommands { @@ -13,24 +13,22 @@ pub enum AuthCommands { impl AuthCommands { pub async fn run(&self, ctx: &Context) { match self { - AuthCommands::Token {} => { - match ctx.config.auth_credentials() { - Some((token_url, client_id, client_secret)) => { - let creds = AuthCredentials::new(token_url, client_id, client_secret); - match creds.get_token().await { - Ok(token) => println!("{}", token), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } + AuthCommands::Token {} => match ctx.config.auth_credentials() { + Some((token_url, client_id, client_secret)) => { + let creds = AuthCredentials::new(token_url, client_id, client_secret); + match creds.get_token().await { + Ok(token) => println!("{}", token), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); } } - None => { - eprintln!("Error: SSO URL, client ID, and client secret are all required"); - process::exit(1); - } } - } + None => { + eprintln!("Error: SSO URL, client ID, and client secret are all required"); + process::exit(1); + } + }, } } } diff --git a/tools/trustify-cli/src/commands/mod.rs b/tools/trustify-cli/src/commands/mod.rs index 74fd2746f..d279a6c9c 100644 --- a/tools/trustify-cli/src/commands/mod.rs +++ b/tools/trustify-cli/src/commands/mod.rs @@ -1,11 +1,11 @@ -pub mod sbom; pub mod auth; +pub mod sbom; use clap::Subcommand; use crate::Context; -pub use sbom::SbomCommands; pub use auth::AuthCommands; +pub use sbom::SbomCommands; #[derive(Subcommand)] pub enum Commands { diff --git a/tools/trustify-cli/src/commands/sbom.rs b/tools/trustify-cli/src/commands/sbom.rs index cb79d51d9..0cb0f5083 100644 --- a/tools/trustify-cli/src/commands/sbom.rs +++ b/tools/trustify-cli/src/commands/sbom.rs @@ -5,8 +5,8 @@ use std::process; use clap::{Subcommand, ValueEnum}; use serde_json::Value; -use crate::api::sbom as sbom_api; use crate::Context; +use crate::api::sbom as sbom_api; /// Output format for SBOM list #[derive(Clone, Default, ValueEnum)] @@ -172,7 +172,7 @@ impl DuplicatesCommands { eprintln!("Operation cancelled."); process::exit(0); } - let final_output = final_output.unwrap(); + let final_output = final_output.unwrap_or("duplicates.json".to_string()); let params = sbom_api::FindDuplicatesParams { batch_size: *batch_size, diff --git a/tools/trustify-cli/src/main.rs b/tools/trustify-cli/src/main.rs index 560c2946b..728cd674a 100644 --- a/tools/trustify-cli/src/main.rs +++ b/tools/trustify-cli/src/main.rs @@ -7,8 +7,8 @@ use std::process; use clap::Parser; -use api::auth::AuthCredentials; use api::ApiClient; +use api::auth::AuthCredentials; use cli::Cli; /// Runtime context containing config and API client From cdf84055f5c2e75a2bad57fd0ea8bc72ed97388b Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Mon, 2 Feb 2026 11:49:07 +0800 Subject: [PATCH 07/12] chore: move to etc --- Cargo.toml | 2 +- {tools => etc}/trustify-cli/Cargo.lock | 0 {tools => etc}/trustify-cli/Cargo.toml | 0 {tools => etc}/trustify-cli/LICENSE | 0 {tools => etc}/trustify-cli/README.md | 0 {tools => etc}/trustify-cli/src/api/auth.rs | 0 {tools => etc}/trustify-cli/src/api/client.rs | 0 {tools => etc}/trustify-cli/src/api/mod.rs | 0 {tools => etc}/trustify-cli/src/api/sbom.rs | 0 {tools => etc}/trustify-cli/src/cli.rs | 0 {tools => etc}/trustify-cli/src/commands/auth.rs | 0 {tools => etc}/trustify-cli/src/commands/mod.rs | 0 {tools => etc}/trustify-cli/src/commands/sbom.rs | 0 {tools => etc}/trustify-cli/src/config.rs | 0 {tools => etc}/trustify-cli/src/main.rs | 0 15 files changed, 1 insertion(+), 1 deletion(-) rename {tools => etc}/trustify-cli/Cargo.lock (100%) rename {tools => etc}/trustify-cli/Cargo.toml (100%) rename {tools => etc}/trustify-cli/LICENSE (100%) rename {tools => etc}/trustify-cli/README.md (100%) rename {tools => etc}/trustify-cli/src/api/auth.rs (100%) rename {tools => etc}/trustify-cli/src/api/client.rs (100%) rename {tools => etc}/trustify-cli/src/api/mod.rs (100%) rename {tools => etc}/trustify-cli/src/api/sbom.rs (100%) rename {tools => etc}/trustify-cli/src/cli.rs (100%) rename {tools => etc}/trustify-cli/src/commands/auth.rs (100%) rename {tools => etc}/trustify-cli/src/commands/mod.rs (100%) rename {tools => etc}/trustify-cli/src/commands/sbom.rs (100%) rename {tools => etc}/trustify-cli/src/config.rs (100%) rename {tools => etc}/trustify-cli/src/main.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index d718c66cc..9787ca331 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ members = [ "test-context", "trustd", "xtask", - "tools/trustify-cli" + "etc/trustify-cli" ] [workspace.package] diff --git a/tools/trustify-cli/Cargo.lock b/etc/trustify-cli/Cargo.lock similarity index 100% rename from tools/trustify-cli/Cargo.lock rename to etc/trustify-cli/Cargo.lock diff --git a/tools/trustify-cli/Cargo.toml b/etc/trustify-cli/Cargo.toml similarity index 100% rename from tools/trustify-cli/Cargo.toml rename to etc/trustify-cli/Cargo.toml diff --git a/tools/trustify-cli/LICENSE b/etc/trustify-cli/LICENSE similarity index 100% rename from tools/trustify-cli/LICENSE rename to etc/trustify-cli/LICENSE diff --git a/tools/trustify-cli/README.md b/etc/trustify-cli/README.md similarity index 100% rename from tools/trustify-cli/README.md rename to etc/trustify-cli/README.md diff --git a/tools/trustify-cli/src/api/auth.rs b/etc/trustify-cli/src/api/auth.rs similarity index 100% rename from tools/trustify-cli/src/api/auth.rs rename to etc/trustify-cli/src/api/auth.rs diff --git a/tools/trustify-cli/src/api/client.rs b/etc/trustify-cli/src/api/client.rs similarity index 100% rename from tools/trustify-cli/src/api/client.rs rename to etc/trustify-cli/src/api/client.rs diff --git a/tools/trustify-cli/src/api/mod.rs b/etc/trustify-cli/src/api/mod.rs similarity index 100% rename from tools/trustify-cli/src/api/mod.rs rename to etc/trustify-cli/src/api/mod.rs diff --git a/tools/trustify-cli/src/api/sbom.rs b/etc/trustify-cli/src/api/sbom.rs similarity index 100% rename from tools/trustify-cli/src/api/sbom.rs rename to etc/trustify-cli/src/api/sbom.rs diff --git a/tools/trustify-cli/src/cli.rs b/etc/trustify-cli/src/cli.rs similarity index 100% rename from tools/trustify-cli/src/cli.rs rename to etc/trustify-cli/src/cli.rs diff --git a/tools/trustify-cli/src/commands/auth.rs b/etc/trustify-cli/src/commands/auth.rs similarity index 100% rename from tools/trustify-cli/src/commands/auth.rs rename to etc/trustify-cli/src/commands/auth.rs diff --git a/tools/trustify-cli/src/commands/mod.rs b/etc/trustify-cli/src/commands/mod.rs similarity index 100% rename from tools/trustify-cli/src/commands/mod.rs rename to etc/trustify-cli/src/commands/mod.rs diff --git a/tools/trustify-cli/src/commands/sbom.rs b/etc/trustify-cli/src/commands/sbom.rs similarity index 100% rename from tools/trustify-cli/src/commands/sbom.rs rename to etc/trustify-cli/src/commands/sbom.rs diff --git a/tools/trustify-cli/src/config.rs b/etc/trustify-cli/src/config.rs similarity index 100% rename from tools/trustify-cli/src/config.rs rename to etc/trustify-cli/src/config.rs diff --git a/tools/trustify-cli/src/main.rs b/etc/trustify-cli/src/main.rs similarity index 100% rename from tools/trustify-cli/src/main.rs rename to etc/trustify-cli/src/main.rs From 55a7c9dec50d6ef4dfb2c5ce642c6613f665023f Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Fri, 6 Feb 2026 19:41:06 +0800 Subject: [PATCH 08/12] feat: Completed the SBOM delete logic and added unit tests. --- .dockerignore | 7 - .github/workflows/container-image.yml | 62 ---- Cargo.lock | 73 +++++ Dockerfile | 31 -- etc/trustify-cli/Cargo.toml | 5 + etc/trustify-cli/src/api/mod.rs | 3 + etc/trustify-cli/src/api/sbom.rs | 131 ++++++-- etc/trustify-cli/src/api/test.rs | 271 ++++++++++++++++ etc/trustify-cli/src/commands/sbom.rs | 61 +++- src/api/auth.rs | 108 ------- src/api/client.rs | 227 -------------- src/api/mod.rs | 4 - src/api/sbom.rs | 434 -------------------------- src/cli.rs | 17 - src/commands/auth.rs | 36 --- src/commands/mod.rs | 32 -- src/commands/sbom.rs | 377 ---------------------- src/config.rs | 38 --- src/main.rs | 52 --- 19 files changed, 507 insertions(+), 1462 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .github/workflows/container-image.yml delete mode 100644 Dockerfile create mode 100644 etc/trustify-cli/src/api/test.rs delete mode 100644 src/api/auth.rs delete mode 100644 src/api/client.rs delete mode 100644 src/api/mod.rs delete mode 100644 src/api/sbom.rs delete mode 100644 src/cli.rs delete mode 100644 src/commands/auth.rs delete mode 100644 src/commands/mod.rs delete mode 100644 src/commands/sbom.rs delete mode 100644 src/config.rs delete mode 100644 src/main.rs diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 2a6c05b7f..000000000 --- a/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -.idea -.DS_Store -/data -.trustify -/target -/.dockerignore -/Containerfile \ No newline at end of file diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml deleted file mode 100644 index 91ae026a3..000000000 --- a/.github/workflows/container-image.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Build and Push Container Image - -on: - push: - branches: - - main - tags: - - 'v*' - pull_request: - branches: - - main - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-and-push: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha,prefix= - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64 diff --git a/Cargo.lock b/Cargo.lock index bcad75064..3b17e8442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2395,6 +2395,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dunce" version = "1.0.5" @@ -2741,6 +2747,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "fs_extra" version = "1.3.0" @@ -4325,6 +4337,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "moka" version = "0.12.14" @@ -5373,6 +5412,32 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -7596,6 +7661,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-context" version = "0.5.4" @@ -8118,11 +8189,13 @@ dependencies = [ "dotenvy", "futures", "indicatif", + "mockall", "reqwest 0.13.2", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "wiremock", ] [[package]] diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d347217e9..000000000 --- a/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -# Build stage -FROM rust:1.83-alpine AS builder - -# Install musl-dev for static linking -RUN apk add --no-cache musl-dev - -WORKDIR /app - -# Copy manifests first for better layer caching -COPY Cargo.toml Cargo.lock ./ - -# Create a dummy main.rs to build dependencies -RUN mkdir src && echo "fn main() {}" > src/main.rs - -# Build dependencies only (this layer will be cached) -RUN cargo build --release && rm -rf src - -# Copy actual source code -COPY src ./src - -# Build the actual binary (touch to update mtime so cargo rebuilds) -RUN touch src/main.rs && cargo build --release - -# Runtime stage - use minimal distroless image -FROM gcr.io/distroless/static-debian12:nonroot - -# Copy the binary from builder -COPY --from=builder /app/target/release/trustify /usr/local/bin/trustify - -# Set the entrypoint -ENTRYPOINT ["trustify"] diff --git a/etc/trustify-cli/Cargo.toml b/etc/trustify-cli/Cargo.toml index 22c64b1f2..20b5910e7 100644 --- a/etc/trustify-cli/Cargo.toml +++ b/etc/trustify-cli/Cargo.toml @@ -17,3 +17,8 @@ serde = { workspace = true , features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } + +[dev-dependencies] +mockall = { workspace = true } +wiremock = { workspace = true } + diff --git a/etc/trustify-cli/src/api/mod.rs b/etc/trustify-cli/src/api/mod.rs index 0cb893a05..8e4c0ef7f 100644 --- a/etc/trustify-cli/src/api/mod.rs +++ b/etc/trustify-cli/src/api/mod.rs @@ -2,3 +2,6 @@ pub mod auth; pub mod client; pub mod sbom; pub use client::ApiClient; + +#[cfg(test)] +mod test; diff --git a/etc/trustify-cli/src/api/sbom.rs b/etc/trustify-cli/src/api/sbom.rs index 0a9641055..fd571a502 100644 --- a/etc/trustify-cli/src/api/sbom.rs +++ b/etc/trustify-cli/src/api/sbom.rs @@ -308,8 +308,67 @@ pub async fn delete(client: &ApiClient, id: &str) -> Result<(), ApiError> { Ok(()) } +/// Delete all SBOMs by listing and deleting each one +pub async fn delete_by_query( + client: &ApiClient, + query: Option<&str>, + dry_run: bool, + concurrency: usize, + limit: Option, +) -> Result { + let params = ListParams { + q: query.map(|s| s.to_string()), + limit, + offset: None, + sort: None, + }; + + let response = list(client, ¶ms).await?; + let parsed: Value = serde_json::from_str(&response) + .map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?; + + let items = parsed + .get("items") + .and_then(|v| v.as_array()) + .ok_or_else(|| ApiError::InternalError("No items in response".to_string()))?; + + let total = items.len() as u32; + + let entries: Vec = items + .iter() + .filter_map(|item| { + let id = item.get("id").and_then(|v| v.as_str())?; + let document_id = item + .get("document_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + Some(DeleteEntry { + id: id.to_string(), + document_id: document_id.to_string(), + }) + }) + .collect(); + + if dry_run { + for entry in &entries { + eprintln!( + "[DRY-RUN] Would delete: {} (document_id: {})", + entry.id, entry.document_id + ); + } + return Ok(DeleteResult { + deleted: 0, + skipped: 0, + failed: 0, + total, + }); + } + + delete_list(client, entries, concurrency).await +} + /// Result of deleting duplicates -pub struct DeleteDuplicatesResult { +pub struct DeleteResult { pub deleted: u32, pub skipped: u32, pub failed: u32, @@ -318,19 +377,13 @@ pub struct DeleteDuplicatesResult { /// Entry to delete with its document_id for logging #[derive(Clone)] -struct DeleteEntry { +pub struct DeleteEntry { id: String, document_id: String, } -/// Delete duplicates from a file with progress bar -pub async fn delete_duplicates( - client: &ApiClient, - input_file: &str, - concurrency: usize, - dry_run: bool, -) -> Result { - // Check if file exists +/// Read delete entries from a file +pub fn read_delete_entries_from_file(input_file: &str) -> Result, ApiError> { let path = Path::new(input_file); if !path.exists() { return Err(ApiError::InternalError(format!( @@ -339,7 +392,6 @@ pub async fn delete_duplicates( ))); } - // Read and parse the file let file = File::open(path) .map_err(|e| ApiError::InternalError(format!("Failed to open input file: {}", e)))?; let reader = BufReader::new(file); @@ -347,7 +399,6 @@ pub async fn delete_duplicates( let groups: Vec = serde_json::from_reader(reader) .map_err(|e| ApiError::InternalError(format!("Failed to parse input file: {}", e)))?; - // Collect all duplicate entries to delete let entries: Vec = groups .iter() .flat_map(|group| { @@ -358,29 +409,22 @@ pub async fn delete_duplicates( }) .collect(); - let total = entries.len() as u32; + Ok(entries) +} - if dry_run { - for entry in &entries { - eprintln!( - "[DRY-RUN] Would delete: {} (document_id: {})", - entry.id, entry.document_id - ); - } - return Ok(DeleteDuplicatesResult { - deleted: 0, - skipped: 0, - failed: 0, - total, - }); - } +/// Delete a list of entries with progress bar +pub async fn delete_list( + client: &ApiClient, + entries: Vec, + concurrency: usize, +) -> Result { + let total = entries.len() as u32; eprintln!( "Deleting {} duplicates with {} concurrent requests...\n", total, concurrency ); - // Set up progress bar let progress = ProgressBar::new(total as u64); progress.set_style( ProgressStyle::default_bar() @@ -405,7 +449,6 @@ pub async fn delete_duplicates( deleted.fetch_add(1, Ordering::Relaxed); } Err(ApiError::NotFound(_)) => { - // SBOM already deleted or doesn't exist - skip silently skipped.fetch_add(1, Ordering::Relaxed); } Err(e) => { @@ -423,10 +466,38 @@ pub async fn delete_duplicates( progress.finish_with_message("complete"); - Ok(DeleteDuplicatesResult { + Ok(DeleteResult { deleted: deleted.load(Ordering::Relaxed), skipped: skipped.load(Ordering::Relaxed), failed: failed.load(Ordering::Relaxed), total, }) } + +/// Delete duplicates from a file with progress bar +pub async fn delete_duplicates( + client: &ApiClient, + input_file: &str, + concurrency: usize, + dry_run: bool, +) -> Result { + let entries = read_delete_entries_from_file(input_file)?; + let total = entries.len() as u32; + + if dry_run { + for entry in &entries { + eprintln!( + "[DRY-RUN] Would delete: {} (document_id: {})", + entry.id, entry.document_id + ); + } + return Ok(DeleteResult { + deleted: 0, + skipped: 0, + failed: 0, + total, + }); + } + + delete_list(client, entries, concurrency).await +} diff --git a/etc/trustify-cli/src/api/test.rs b/etc/trustify-cli/src/api/test.rs new file mode 100644 index 000000000..570b8b704 --- /dev/null +++ b/etc/trustify-cli/src/api/test.rs @@ -0,0 +1,271 @@ +use super::client::{ApiClient, ApiError}; +use super::sbom::{ListParams, delete_by_query, list}; +use serde_json::json; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Helper function to create a test client connected to a mock server +async fn create_test_client() -> (ApiClient, MockServer) { + let mock_server = MockServer::start().await; + let client = ApiClient::new(&mock_server.uri(), None, None); + (client, mock_server) +} + +/// Helper function to create a mock response with 3 SBOM items +fn create_mock_sbom_response() -> serde_json::Value { + json!({ + "items": [ + { + "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", + "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.6", + "labels": { + "type": "spdx" + }, + "data_licenses": ["CC0-1.0"], + "published": "2024-09-17T11:31:02Z", + "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], + "suppliers": [], + "name": "MTV-2.6", + "number_of_packages": 5388, + "sha256": "sha256:c7caabdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565614", + "sha384": "sha384:7c5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed1", + "sha512": "sha512:411b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e391", + "size": 8941362, + "ingested": "2026-02-03T15:18:54.375162Z", + "described_by": [ + { + "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0419", + "name": "MTV-2.6", + "group": null, + "version": "2.6", + "purl": [], + "cpe": [ + "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el9:*", + "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el8:*" + ], + "licenses": [ + { + "license_name": "NOASSERTION", + "license_type": "declared" + }, + { + "license_name": "NOASSERTION", + "license_type": "concluded" + } + ], + "licenses_ref_mapping": [] + } + ] + }, + { + "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", + "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.7", + "labels": { + "type": "spdx" + }, + "data_licenses": ["MIT"], + "published": "2024-09-18T12:00:00Z", + "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], + "suppliers": [], + "name": "MTV-2.7", + "number_of_packages": 400, + "sha256": "sha256:d8ebdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565615", + "sha384": "sha384:8d5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed2", + "sha512": "sha512:522b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e392", + "size": 7500000, + "ingested": "2026-02-04T10:00:00.000000Z", + "described_by": [ + { + "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0420", + "name": "MTV-2.7", + "group": null, + "version": "2.7", + "purl": [], + "cpe": [ + "cpe:/a:redhat:migration_toolkit_virtualization:2.7:*:el9:*" + ], + "licenses": [ + { + "license_name": "MIT", + "license_type": "declared" + } + ], + "licenses_ref_mapping": [] + } + ] + }, + { + "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", + "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.8", + "labels": { + "type": "cyclonedx" + }, + "data_licenses": ["Apache-2.0"], + "published": "2024-09-19T13:00:00Z", + "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], + "suppliers": [], + "name": "MTV-2.8", + "number_of_packages": 250, + "sha256": "sha256:e9fbdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565616", + "sha384": "sha384:9e5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed3", + "sha512": "sha512:633b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e393", + "size": 6000000, + "ingested": "2026-02-05T08:00:00.000000Z", + "described_by": [ + { + "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0421", + "name": "MTV-2.8", + "group": null, + "version": "2.8", + "purl": [], + "cpe": [ + "cpe:/a:redhat:migration_toolkit_virtualization:2.8:*:el9:*" + ], + "licenses": [ + { + "license_name": "Apache-2.0", + "license_type": "declared" + } + ], + "licenses_ref_mapping": [] + } + ] + } + ], + "total": 3 + }) +} + +#[tokio::test] +async fn test_list_sboms_success() { + let (client, mock_server) = create_test_client().await; + + let mock_response = create_mock_sbom_response(); + + Mock::given(method("GET")) + .and(path("/api/v2/sbom")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .expect(1) + .mount(&mock_server) + .await; + + let params = ListParams { + q: None, + limit: Some(10), + offset: Some(0), + sort: None, + }; + + let result = list(&client, ¶ms).await; + + assert!(result.is_ok()); + let response_json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(response_json["total"], 3); + assert_eq!(response_json["items"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn test_delete_by_query_success() { + let (client, mock_server) = create_test_client().await; + + let mock_response = create_mock_sbom_response(); + + Mock::given(method("GET")) + .and(path("/api/v2/sbom")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path_regex(r"^/api/v2/sbom/urn:uuid:019c.*$")) + .respond_with(ResponseTemplate::new(204)) + .expect(3) + .mount(&mock_server) + .await; + + let result: Result = + delete_by_query(&client, Some("name:test"), false, 2, None).await; + + assert!(result.is_ok()); + let delete_result = result.unwrap(); + assert_eq!(delete_result.total, 3); + assert_eq!(delete_result.deleted, 3); + assert_eq!(delete_result.skipped, 0); + assert_eq!(delete_result.failed, 0); +} + +#[tokio::test] +async fn test_delete_by_query_dry_run() { + let (client, mock_server) = create_test_client().await; + + let mock_response = create_mock_sbom_response(); + + Mock::given(method("GET")) + .and(path("/api/v2/sbom")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .expect(1) + .mount(&mock_server) + .await; + + let result: Result = + delete_by_query(&client, Some("name:test"), true, 2, None).await; + + assert!(result.is_ok()); + let delete_result = result.unwrap(); + assert_eq!(delete_result.total, 3); + assert_eq!(delete_result.deleted, 0); + assert_eq!(delete_result.skipped, 0); + assert_eq!(delete_result.failed, 0); +} + +#[tokio::test] +async fn test_delete_by_query_with_not_found_and_failed() { + let (client, mock_server) = create_test_client().await; + + let mock_response = create_mock_sbom_response(); + + Mock::given(method("GET")) + .and(path("/api/v2/sbom")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", + )) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", + )) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", + )) + .respond_with(ResponseTemplate::new(502)) + .expect(3) + .mount(&mock_server) + .await; + + let result: Result = + delete_by_query(&client, Some("name:test"), false, 2, None).await; + + assert!(result.is_ok()); + let delete_result = result.unwrap(); + assert_eq!(delete_result.total, 3); + assert_eq!(delete_result.deleted, 1); + assert_eq!(delete_result.skipped, 1); + assert_eq!(delete_result.failed, 1); +} diff --git a/etc/trustify-cli/src/commands/sbom.rs b/etc/trustify-cli/src/commands/sbom.rs index 0cb0f5083..43c0b18b9 100644 --- a/etc/trustify-cli/src/commands/sbom.rs +++ b/etc/trustify-cli/src/commands/sbom.rs @@ -93,6 +93,14 @@ pub enum SbomCommands { /// Perform a dry run without actually deleting #[arg(long)] dry_run: bool, + + /// Number of concurrent delete requests (default: 10) + #[arg(long, default_value = "10")] + concurrency: usize, + + /// Limit the number of SBOMs to query and delete (default: 100) + #[arg(long, default_value = "100")] + limit: Option, }, /// Manage duplicate SBOMs Duplicates { @@ -137,16 +145,55 @@ impl SbomCommands { } } } - SbomCommands::Delete { id, query, dry_run } => { - println!( - "SBOM delete command executed successfully!{}", - if *dry_run { " (dry-run)" } else { "" } - ); + SbomCommands::Delete { + id, + query, + dry_run, + concurrency, + limit, + } => { if let Some(i) = id { - println!(" ID: {}", i); + match sbom_api::delete(&ctx.client, i).await { + Ok(_) => println!("Deleted SBOM ID: {}", i), + Err(e) => { + eprintln!("Error deleting ID {}: {}", i, e); + process::exit(1); + } + } } if let Some(q) = query { - println!(" Query: {}", q); + match sbom_api::delete_by_query( + &ctx.client, + Some(q.as_str()), + *dry_run, + *concurrency, + *limit, + ) + .await + { + Ok(result) => { + if *dry_run { + println!("[DRY-RUN] Would delete {} SBOM(s)", result.total); + } else { + let mut msg = format!("Deleted {} SBOM(s)", result.deleted); + if result.skipped > 0 { + msg.push_str(&format!( + ", {} skipped (not found)", + result.skipped + )); + } + if result.failed > 0 { + msg.push_str(&format!(", {} failed", result.failed)); + } + msg.push_str(&format!(" out of {} total", result.total)); + println!("{}", msg); + } + } + Err(e) => { + eprintln!("Error deleting SBOMs: {}", e); + process::exit(1); + } + } } } } diff --git a/src/api/auth.rs b/src/api/auth.rs deleted file mode 100644 index 79199ca66..000000000 --- a/src/api/auth.rs +++ /dev/null @@ -1,108 +0,0 @@ -use reqwest::Client; -use serde::Deserialize; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum AuthError { - #[error("Failed to connect to SSO server: {0}")] - ConnectionError(#[from] reqwest::Error), - - #[error("Authentication failed: Invalid client_id, client_secret, or SSO URL. Please verify your credentials.")] - AuthenticationFailed, - - #[error("SSO server returned an error: {0}")] - ServerError(String), -} - -/// Authentication credentials for token refresh -#[derive(Clone)] -pub struct AuthCredentials { - pub token_url: String, - pub client_id: String, - pub client_secret: String, -} - -impl AuthCredentials { - /// Build credentials from SSO URL and client credentials - pub fn new(sso_url: &str, client_id: &str, client_secret: &str) -> Self { - let token_url = build_token_url(sso_url); - Self { - token_url, - client_id: client_id.to_string(), - client_secret: client_secret.to_string(), - } - } - - /// Get a token using these credentials - pub async fn get_token(&self) -> Result { - get_token(&self.token_url, &self.client_id, &self.client_secret).await - } -} - -/// Build the token URL from an SSO base URL -pub fn build_token_url(sso_url: &str) -> String { - if sso_url.ends_with("/token") { - sso_url.to_string() - } else if sso_url.ends_with('/') { - format!("{}protocol/openid-connect/token", sso_url) - } else { - format!("{}/protocol/openid-connect/token", sso_url) - } -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: String, - #[allow(dead_code)] - token_type: String, - #[allow(dead_code)] - expires_in: Option, -} - -#[derive(Deserialize)] -struct ErrorResponse { - error: String, - error_description: Option, -} - -/// Retrieves an OAuth2 access token using client credentials grant -pub async fn get_token( - token_url: &str, - client_id: &str, - client_secret: &str, -) -> Result { - let client = Client::new(); - - let response = client - .post(token_url) - .form(&[ - ("grant_type", "client_credentials"), - ("client_id", client_id), - ("client_secret", client_secret), - ]) - .send() - .await?; - - if response.status().is_success() { - let token_response: TokenResponse = response.json().await?; - Ok(token_response.access_token) - } else if response.status().as_u16() == 401 || response.status().as_u16() == 400 { - // Try to get error details - if let Ok(error_response) = response.json::().await { - if error_response.error == "invalid_client" - || error_response.error == "unauthorized_client" - { - return Err(AuthError::AuthenticationFailed); - } - let msg = error_response - .error_description - .unwrap_or(error_response.error); - return Err(AuthError::ServerError(msg)); - } - Err(AuthError::AuthenticationFailed) - } else { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - Err(AuthError::ServerError(format!("HTTP {}: {}", status, body))) - } -} diff --git a/src/api/client.rs b/src/api/client.rs deleted file mode 100644 index d06704d0f..000000000 --- a/src/api/client.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use reqwest::{Client, RequestBuilder, StatusCode}; -use thiserror::Error; -use tokio::sync::RwLock; -use tokio::time::sleep; - - -const MAX_RETRIES: u32 = 3; -const RETRY_DELAY_MS: u64 = 1000; - -#[derive(Error, Debug, Clone)] -pub enum ApiError { - #[error("Network error: {0}")] - NetworkError(String), - - #[error("HTTP {0}: {1}")] - HttpError(u16, String), - - #[error("HTTP 404: Resource not found")] - NotFound(String), - - #[error("HTTP 401: Please check your authentication credentials")] - Unauthorized, - - #[error("HTTP 401: Token expired")] - TokenExpired, - - #[error("HTTP {0}: Server timeout")] - Timeout(u16), - - #[error("HTTP {0}: {1}")] - ServerError(u16, String), - - #[error("{0}")] - InternalError(String), -} - -impl From for ApiError { - fn from(e: reqwest::Error) -> Self { - if e.is_timeout() { - ApiError::Timeout(0) // 0 indicates network-level timeout (no HTTP response) - } else if e.is_connect() { - ApiError::NetworkError(format!("Connection failed: {}", e)) - } else if e.is_request() { - ApiError::NetworkError(format!("Request error: {}", e)) - } else { - ApiError::NetworkError(e.to_string()) - } - } -} - -// Re-export AuthCredentials from auth module -pub use super::auth::AuthCredentials; - -/// API client for Trustify with retry and token refresh support -#[derive(Clone)] -pub struct ApiClient { - client: Client, - base_url: String, - token: Arc>>, - auth_credentials: Option, -} - -impl ApiClient { - pub fn new( - base_url: &str, - token: Option, - auth_credentials: Option, - ) -> Self { - let base_url = base_url.trim_end_matches('/').to_string(); - - Self { - client: Client::builder() - .timeout(Duration::from_secs(60)) - .build() - .unwrap_or_else(|_| Client::new()), - base_url, - token: Arc::new(RwLock::new(token)), - auth_credentials, - } - } - - /// Build the full API URL - pub fn url(&self, path: &str) -> String { - format!("{}/api{}", self.base_url, path) - } - - /// Add authorization header if token is present - async fn authorize(&self, request: RequestBuilder) -> RequestBuilder { - let token = self.token.read().await; - match &*token { - Some(t) => request.bearer_auth(t), - None => request, - } - } - - /// Refresh the token using stored credentials - async fn refresh_token(&self) -> Result<(), ApiError> { - let creds = self - .auth_credentials - .as_ref() - .ok_or(ApiError::Unauthorized)?; - - eprintln!("Token expired, refreshing..."); - - match creds.get_token().await { - Ok(new_token) => { - let mut token = self.token.write().await; - *token = Some(new_token); - eprintln!("Token refreshed successfully"); - Ok(()) - } - Err(e) => { - eprintln!("Failed to refresh token: {}", e); - Err(ApiError::Unauthorized) - } - } - } - - /// Perform a GET request with retry logic - pub async fn get(&self, path: &str) -> Result { - self.execute_with_retry(|| async { - let url = self.url(path); - let request = self.client.get(&url); - let response = self.authorize(request).await.send().await?; - self.handle_response(response).await - }) - .await - } - - /// Perform a GET request with query parameters and retry logic - pub async fn get_with_query( - &self, - path: &str, - query: &T, - ) -> Result { - self.execute_with_retry(|| async { - let url = self.url(path); - let request = self.client.get(&url).query(query); - let response = self.authorize(request).await.send().await?; - self.handle_response(response).await - }) - .await - } - - /// Perform a DELETE request with retry logic - pub async fn delete(&self, path: &str) -> Result { - self.execute_with_retry(|| async { - let url = self.url(path); - let request = self.client.delete(&url); - let response = self.authorize(request).await.send().await?; - self.handle_response(response).await - }) - .await - } - - /// Execute a request with retry logic for timeouts and token refresh - async fn execute_with_retry(&self, f: F) -> Result - where - F: Fn() -> Fut, - Fut: std::future::Future>, - { - let mut last_error = ApiError::NetworkError("No attempts made".to_string()); - let mut token_refreshed = false; - - for attempt in 0..MAX_RETRIES { - match f().await { - Ok(result) => return Ok(result), - Err(ApiError::TokenExpired) => { - if !token_refreshed - && self.auth_credentials.is_some() - && self.refresh_token().await.is_ok() - { - token_refreshed = true; - continue; // Retry with new token - } - return Err(ApiError::Unauthorized); - } - Err(ref e @ ApiError::Timeout(_)) - | Err(ref e @ ApiError::ServerError(_, _)) - | Err(ref e @ ApiError::NetworkError(_)) - if attempt < MAX_RETRIES - 1 => - { - let delay = RETRY_DELAY_MS * (attempt as u64 + 1); - eprintln!( - "{}, retrying in {}ms... (attempt {}/{})", - e, - delay, - attempt + 1, - MAX_RETRIES - ); - sleep(Duration::from_millis(delay)).await; - last_error = e.clone(); - } - Err(e) => return Err(e), - } - } - - Err(last_error) - } - - async fn handle_response(&self, response: reqwest::Response) -> Result { - let status = response.status(); - let status_code = status.as_u16(); - - if status.is_success() { - Ok(response.text().await?) - } else if status == StatusCode::NOT_FOUND { - Err(ApiError::NotFound("Resource not found".to_string())) - } else if status == StatusCode::UNAUTHORIZED { - Err(ApiError::TokenExpired) - } else if status == StatusCode::FORBIDDEN { - Err(ApiError::Unauthorized) - } else if status == StatusCode::GATEWAY_TIMEOUT || status == StatusCode::REQUEST_TIMEOUT { - Err(ApiError::Timeout(status_code)) - } else { - let body = response.text().await.unwrap_or_default(); - if status.is_server_error() { - Err(ApiError::ServerError(status_code, body)) - } else { - Err(ApiError::HttpError(status_code, body)) - } - } - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs deleted file mode 100644 index 666dc0fd0..000000000 --- a/src/api/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod client; -pub mod sbom; -pub mod auth; -pub use client::ApiClient; diff --git a/src/api/sbom.rs b/src/api/sbom.rs deleted file mode 100644 index 57d490543..000000000 --- a/src/api/sbom.rs +++ /dev/null @@ -1,434 +0,0 @@ -use std::collections::HashMap; -use std::fs::File; -use std::io::{BufReader, Write}; -use std::path::Path; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Arc; - -use futures::future::join_all; -use futures::stream::{self, StreamExt}; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::sync::Mutex; - -use super::client::{ApiClient, ApiError}; - -const SBOM_PATH: &str = "/v2/sbom"; - -/// Query parameters for listing SBOMs -#[derive(Default, Serialize)] -pub struct ListParams { - #[serde(skip_serializing_if = "Option::is_none")] - pub q: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sort: Option, -} - -/// Parameters for find duplicates -pub struct FindDuplicatesParams { - pub batch_size: u32, - pub concurrency: usize, -} - -/// SBOM entry for duplicate detection -#[derive(Debug, Clone)] -struct SbomEntry { - id: String, - document_id: String, - published: Option, -} - -/// Duplicate group output format -#[derive(Debug, Serialize, Deserialize)] -pub struct DuplicateGroup { - pub document_id: String, - pub published: Option, - pub id: String, - pub duplicates: Vec, -} - -/// Get SBOM by ID - returns raw JSON -pub async fn get(client: &ApiClient, id: &str) -> Result { - let path = format!("{}/{}", SBOM_PATH, id); - client.get(&path).await -} - -/// List SBOMs with optional query parameters - returns raw JSON -pub async fn list(client: &ApiClient, params: &ListParams) -> Result { - client.get_with_query(SBOM_PATH, params).await -} - -/// Fetch a single page and extract SBOM entries -async fn fetch_page( - client: &ApiClient, - batch_size: u32, - offset: u32, -) -> Result, ApiError> { - let list_params = ListParams { - q: None, - limit: Some(batch_size), - offset: Some(offset), - sort: None, - }; - - let response = list(client, &list_params).await?; - let parsed: Value = serde_json::from_str(&response) - .map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?; - - let items = parsed - .get("items") - .and_then(|v| v.as_array()) - .ok_or_else(|| ApiError::InternalError("No items in response".to_string()))?; - - let entries: Vec = items - .iter() - .filter_map(|item| { - let id = item.get("id").and_then(|v| v.as_str())?.to_string(); - let document_id = item - .get("document_id") - .and_then(|v| v.as_str())? - .to_string(); - let published = item - .get("published") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - if document_id.is_empty() { - None - } else { - Some(SbomEntry { - id, - document_id, - published, - }) - } - }) - .collect(); - - Ok(entries) -} - -/// Worker that fetches assigned pages sequentially -async fn fetch_worker( - worker_id: usize, - client: ApiClient, - pages: Vec, - batch_size: u32, - progress_bar: ProgressBar, - results: Arc>>, -) { - let mut fetched: u64 = 0; - - for offset in pages { - match fetch_page(&client, batch_size, offset).await { - Ok(entries) => { - fetched += entries.len() as u64; - progress_bar.set_position(fetched); - results.lock().await.extend(entries); - } - Err(e) => { - progress_bar.println(format!( - "Worker {}: Error at offset {}: {}", - worker_id, offset, e - )); - } - } - } - - progress_bar.finish_with_message("done"); -} - -/// Find duplicate SBOMs by document_id and save to file -pub async fn find_duplicates( - client: &ApiClient, - params: &FindDuplicatesParams, - output_file: &Option, -) -> Result, ApiError> { - let batch_size = params.batch_size; - let concurrency = params.concurrency; - - // First, get the total count - let first_page = list( - client, - &ListParams { - q: None, - limit: Some(1), - offset: Some(0), - sort: None, - }, - ) - .await?; - - let parsed: Value = serde_json::from_str(&first_page) - .map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?; - - let total = parsed.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - - if total == 0 { - eprintln!("No SBOMs found"); - return Ok(Vec::new()); - } - - eprintln!("Fetching {} SBOMs with {} workers...\n", total, concurrency); - - // Calculate page offsets - let num_pages = total.div_ceil(batch_size); - let all_offsets: Vec = (0..num_pages).map(|i| i * batch_size).collect(); - - // Distribute pages evenly among workers - let mut worker_pages: Vec> = vec![Vec::new(); concurrency]; - for (i, offset) in all_offsets.into_iter().enumerate() { - worker_pages[i % concurrency].push(offset); - } - - // Calculate how many SBOMs each worker will fetch - let worker_counts: Vec = worker_pages - .iter() - .map(|pages| { - pages - .iter() - .map(|&offset| { - let remaining = total.saturating_sub(offset); - remaining.min(batch_size) as u64 - }) - .sum() - }) - .collect(); - - // Set up progress bars - let multi_progress = MultiProgress::new(); - let style = ProgressStyle::default_bar() - .template("{prefix:>12} [{bar:30.cyan/blue}] {pos}/{len} ({percent}%)") - .unwrap() - .progress_chars("█▓░"); - - let results: Arc>> = Arc::new(Mutex::new(Vec::new())); - - // Spawn workers - let mut handles = Vec::new(); - for (worker_id, pages) in worker_pages.into_iter().enumerate() { - if pages.is_empty() { - continue; - } - - let worker_total = worker_counts[worker_id]; - let pb = multi_progress.add(ProgressBar::new(worker_total)); - pb.set_style(style.clone()); - pb.set_prefix(format!("Worker {}", worker_id + 1)); - - let client = client.clone(); - let results = Arc::clone(&results); - - handles.push(tokio::spawn(fetch_worker( - worker_id + 1, - client, - pages, - batch_size, - pb, - results, - ))); - } - - // Wait for all workers to complete - join_all(handles).await; - - let all_entries = Arc::try_unwrap(results) - .map_err(|_| ApiError::InternalError("Failed to unwrap entries".to_string()))? - .into_inner(); - - eprintln!("\nProcessing {} SBOMs for duplicates...", all_entries.len()); - - // Group by document_id - let mut groups: HashMap> = HashMap::new(); - for entry in all_entries { - groups - .entry(entry.document_id.clone()) - .or_default() - .push(entry); - } - - // Find duplicates (groups with more than one entry) - let mut duplicate_groups: Vec = Vec::new(); - - for (document_id, mut entries) in groups { - if entries.len() <= 1 { - continue; - } - - // Sort by published date descending (most recent first) - entries.sort_by(|a, b| match (&b.published, &a.published) { - (Some(b_pub), Some(a_pub)) => b_pub.cmp(a_pub), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - }); - - let most_recent = entries.remove(0); - let duplicates: Vec = entries.into_iter().map(|e| e.id).collect(); - - duplicate_groups.push(DuplicateGroup { - document_id, - published: most_recent.published, - id: most_recent.id, - duplicates, - }); - } - - eprintln!( - "Found {} document(s) with duplicates", - duplicate_groups.len() - ); - - // Save to file - let output_path = output_file - .as_ref() - .map(|s| s.as_str()) - .unwrap_or("duplicates.json"); - - let json = serde_json::to_string_pretty(&duplicate_groups) - .map_err(|e| ApiError::InternalError(format!("Failed to serialize results: {}", e)))?; - - let mut file = File::create(output_path) - .map_err(|e| ApiError::InternalError(format!("Failed to create output file: {}", e)))?; - - file.write_all(json.as_bytes()) - .map_err(|e| ApiError::InternalError(format!("Failed to write to file: {}", e)))?; - - Ok(duplicate_groups) -} - -/// Delete an SBOM by ID -pub async fn delete(client: &ApiClient, id: &str) -> Result<(), ApiError> { - let path = format!("{}/{}", SBOM_PATH, id); - client.delete(&path).await?; - Ok(()) -} - -/// Result of deleting duplicates -pub struct DeleteDuplicatesResult { - pub deleted: u32, - pub skipped: u32, - pub failed: u32, - pub total: u32, -} - -/// Entry to delete with its document_id for logging -#[derive(Clone)] -struct DeleteEntry { - id: String, - document_id: String, -} - -/// Delete duplicates from a file with progress bar -pub async fn delete_duplicates( - client: &ApiClient, - input_file: &str, - concurrency: usize, - dry_run: bool, -) -> Result { - // Check if file exists - let path = Path::new(input_file); - if !path.exists() { - return Err(ApiError::InternalError(format!( - "Input file not found: {}", - input_file - ))); - } - - // Read and parse the file - let file = File::open(path) - .map_err(|e| ApiError::InternalError(format!("Failed to open input file: {}", e)))?; - let reader = BufReader::new(file); - - let groups: Vec = serde_json::from_reader(reader) - .map_err(|e| ApiError::InternalError(format!("Failed to parse input file: {}", e)))?; - - // Collect all duplicate entries to delete - let entries: Vec = groups - .iter() - .flat_map(|group| { - group.duplicates.iter().map(|id| DeleteEntry { - id: id.clone(), - document_id: group.document_id.clone(), - }) - }) - .collect(); - - let total = entries.len() as u32; - - if dry_run { - for entry in &entries { - eprintln!( - "[DRY-RUN] Would delete: {} (document_id: {})", - entry.id, entry.document_id - ); - } - return Ok(DeleteDuplicatesResult { - deleted: 0, - skipped: 0, - failed: 0, - total, - }); - } - - eprintln!( - "Deleting {} duplicates with {} concurrent requests...\n", - total, concurrency - ); - - // Set up progress bar - let progress = ProgressBar::new(total as u64); - progress.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}") - .unwrap() - .progress_chars("█▓░"), - ); - - let deleted = Arc::new(AtomicU32::new(0)); - let skipped = Arc::new(AtomicU32::new(0)); - let failed = Arc::new(AtomicU32::new(0)); - - stream::iter(entries) - .for_each_concurrent(concurrency, |entry| { - let client = client.clone(); - let deleted = Arc::clone(&deleted); - let skipped = Arc::clone(&skipped); - let failed = Arc::clone(&failed); - let progress = progress.clone(); - async move { - match delete(&client, &entry.id).await { - Ok(_) => { - deleted.fetch_add(1, Ordering::Relaxed); - } - Err(ApiError::NotFound(_)) => { - // SBOM already deleted or doesn't exist - skip silently - skipped.fetch_add(1, Ordering::Relaxed); - } - Err(e) => { - failed.fetch_add(1, Ordering::Relaxed); - progress.println(format!( - "Failed to delete {} (document_id: {}): {}", - entry.id, entry.document_id, e - )); - } - } - progress.inc(1); - } - }) - .await; - - progress.finish_with_message("complete"); - - Ok(DeleteDuplicatesResult { - deleted: deleted.load(Ordering::Relaxed), - skipped: skipped.load(Ordering::Relaxed), - failed: failed.load(Ordering::Relaxed), - total, - }) -} diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index fbcae8ae8..000000000 --- a/src/cli.rs +++ /dev/null @@ -1,17 +0,0 @@ -use clap::Parser; - -use crate::commands::Commands; -use crate::config::Config; - -/// Trustify CLI - Software Supply-Chain Security tool -#[derive(Parser)] -#[command(name = "trustify")] -#[command(about = "CLI for interacting with the Trustify API", long_about = None)] -#[command(version)] -pub struct Cli { - #[command(flatten)] - pub config: Config, - - #[command(subcommand)] - pub command: Commands, -} diff --git a/src/commands/auth.rs b/src/commands/auth.rs deleted file mode 100644 index 7ca608fb5..000000000 --- a/src/commands/auth.rs +++ /dev/null @@ -1,36 +0,0 @@ -use clap::Subcommand; - -use std::process; -use crate::Context; -use crate::api::auth::AuthCredentials; - -#[derive(Subcommand)] -pub enum AuthCommands { - /// Get authentication token - Token {}, -} - -impl AuthCommands { - pub async fn run(&self, ctx: &Context) { - match self { - AuthCommands::Token {} => { - match ctx.config.auth_credentials() { - Some((token_url, client_id, client_secret)) => { - let creds = AuthCredentials::new(token_url, client_id, client_secret); - match creds.get_token().await { - Ok(token) => println!("{}", token), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } - } - None => { - eprintln!("Error: SSO URL, client ID, and client secret are all required"); - process::exit(1); - } - } - } - } - } -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs deleted file mode 100644 index 74fd2746f..000000000 --- a/src/commands/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -pub mod sbom; -pub mod auth; - -use clap::Subcommand; - -use crate::Context; -pub use sbom::SbomCommands; -pub use auth::AuthCommands; - -#[derive(Subcommand)] -pub enum Commands { - /// SBOM management commands - Sbom { - #[command(subcommand)] - command: SbomCommands, - }, - - /// Authentication commands - Auth { - #[command(subcommand)] - command: AuthCommands, - }, -} - -impl Commands { - pub async fn run(&self, ctx: &Context) { - match self { - Commands::Sbom { command } => command.run(ctx).await, - Commands::Auth { command } => command.run(ctx).await, - } - } -} diff --git a/src/commands/sbom.rs b/src/commands/sbom.rs deleted file mode 100644 index cb79d51d9..000000000 --- a/src/commands/sbom.rs +++ /dev/null @@ -1,377 +0,0 @@ -use std::io::{self, Write}; -use std::path::Path; -use std::process; - -use clap::{Subcommand, ValueEnum}; -use serde_json::Value; - -use crate::api::sbom as sbom_api; -use crate::Context; - -/// Output format for SBOM list -#[derive(Clone, Default, ValueEnum)] -pub enum ListFormat { - /// Only output the SBOM ID - Id, - /// Output id, name, document_id - Name, - /// Output id, name, document_id, ingested, published, size - Short, - /// Output complete JSON document - #[default] - Full, -} - -#[derive(Subcommand)] -pub enum DuplicatesCommands { - /// Find duplicates by namespace - Find { - /// Batch size for querying duplicates - #[arg(short = 'b', long, default_value = "100")] - batch_size: u32, - - /// Number of concurrent fetch requests (default: 4) - #[arg(short = 'j', long, default_value = "4")] - concurrency: usize, - - /// Output file - #[arg(long, default_value = "duplicates.json")] - output: Option, - }, - /// Delete duplicates - Delete { - /// Input file - #[arg(long, default_value = "duplicates.json")] - input: Option, - - /// Number of concurrent delete requests (default: 8) - #[arg(short = 'j', long, default_value = "8")] - concurrency: usize, - - /// Perform a dry run without actually deleting - #[arg(long)] - dry_run: bool, - }, -} - -#[derive(Subcommand)] -pub enum SbomCommands { - /// Get SBOM by ID - Get { - /// SBOM ID - id: String, - }, - /// List SBOMs - List { - /// Query filter for SBOMs - #[arg(long)] - query: Option, - /// Limit the number of results - #[arg(long)] - limit: Option, - /// Offset the results - #[arg(long)] - offset: Option, - /// Sort the results - /// Example: `purl:qualifiers:type:desc` - #[arg(long)] - sort: Option, - /// Output format: id, name, short, full (default: full) - #[arg(long, value_enum, default_value = "full")] - format: ListFormat, - }, - /// Delete SBOMs - Delete { - /// SBOM ID - #[arg(long)] - id: Option, - - /// Query filter for SBOMs to delete - #[arg(long)] - query: Option, - - /// Perform a dry run without actually deleting - #[arg(long)] - dry_run: bool, - }, - /// Manage duplicate SBOMs - Duplicates { - #[command(subcommand)] - command: DuplicatesCommands, - }, -} - -impl SbomCommands { - pub async fn run(&self, ctx: &Context) { - match self { - SbomCommands::Duplicates { command } => { - command.run(ctx).await; - } - SbomCommands::Get { id } => match sbom_api::get(&ctx.client, id).await { - Ok(json) => println!("{}", json), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - }, - SbomCommands::List { - query, - limit, - offset, - sort, - format, - } => { - let params = sbom_api::ListParams { - q: query.clone(), - limit: *limit, - offset: *offset, - sort: sort.clone(), - }; - match sbom_api::list(&ctx.client, ¶ms).await { - Ok(json) => { - format_list_output(&json, format); - } - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } - } - SbomCommands::Delete { id, query, dry_run } => { - println!( - "SBOM delete command executed successfully!{}", - if *dry_run { " (dry-run)" } else { "" } - ); - if let Some(i) = id { - println!(" ID: {}", i); - } - if let Some(q) = query { - println!(" Query: {}", q); - } - } - } - } -} - -impl DuplicatesCommands { - pub async fn run(&self, ctx: &Context) { - match self { - DuplicatesCommands::Find { - batch_size, - concurrency, - output, - } => { - let output_path = output - .as_ref() - .map(|s| s.as_str()) - .unwrap_or("duplicates.json"); - - // Check if output file exists - let final_output = check_output_file(output_path); - if final_output.is_none() { - eprintln!("Operation cancelled."); - process::exit(0); - } - let final_output = final_output.unwrap(); - - let params = sbom_api::FindDuplicatesParams { - batch_size: *batch_size, - concurrency: *concurrency, - }; - match sbom_api::find_duplicates(&ctx.client, ¶ms, &Some(final_output.clone())) - .await - { - Ok(groups) => { - let total_duplicates: usize = - groups.iter().map(|g| g.duplicates.len()).sum(); - println!( - "Found {} document(s) with {} duplicate(s). Saved to {}", - groups.len(), - total_duplicates, - final_output - ); - } - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } - } - DuplicatesCommands::Delete { - input, - concurrency, - dry_run, - } => { - let input_path = input - .as_ref() - .map(|s| s.as_str()) - .unwrap_or("duplicates.json"); - - match sbom_api::delete_duplicates(&ctx.client, input_path, *concurrency, *dry_run) - .await - { - Ok(result) => { - if *dry_run { - println!("[DRY-RUN] Would delete {} duplicate(s)", result.total); - } else { - let mut msg = format!("Deleted {} duplicate(s)", result.deleted); - if result.skipped > 0 { - msg.push_str(&format!(", {} skipped (not found)", result.skipped)); - } - if result.failed > 0 { - msg.push_str(&format!(", {} failed", result.failed)); - } - msg.push_str(&format!(" out of {} total", result.total)); - println!("{}", msg); - } - } - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } - } - } - } -} - -/// Format and print list output based on the specified format -fn format_list_output(json: &str, format: &ListFormat) { - let parsed: Value = match serde_json::from_str(json) { - Ok(v) => v, - Err(e) => { - eprintln!("Error parsing response: {}", e); - process::exit(1); - } - }; - - // The API returns { "items": [...], "total": N } - let items = match parsed.get("items").and_then(|v| v.as_array()) { - Some(arr) => arr, - None => { - // If no items array, just print the raw JSON - println!("{}", json); - return; - } - }; - - match format { - ListFormat::Full => { - println!("{}", json); - } - ListFormat::Id => { - for item in items { - if let Some(id) = item.get("id").and_then(|v| v.as_str()) { - println!("{}", id); - } - } - } - ListFormat::Name => { - let result: Vec = items - .iter() - .map(|item| { - serde_json::json!({ - "id": item.get("id"), - "name": item.get("name"), - "document_id": item.get("document_id") - }) - }) - .collect(); - println!("{}", serde_json::to_string(&result).unwrap_or_default()); - } - ListFormat::Short => { - let result: Vec = items - .iter() - .map(|item| { - serde_json::json!({ - "id": item.get("id"), - "name": item.get("name"), - "document_id": item.get("document_id"), - "ingested": item.get("ingested"), - "published": item.get("published"), - "size": item.get("size"), - }) - }) - .collect(); - println!("{}", serde_json::to_string(&result).unwrap_or_default()); - } - } -} - -/// Check if output file exists and prompt user for action -/// Returns None if user cancels, Some(path) with the final path to use -fn check_output_file(output_path: &str) -> Option { - let path = Path::new(output_path); - - if !path.exists() { - return Some(output_path.to_string()); - } - - // File exists, ask user what to do - loop { - eprint!( - "File '{}' already exists. Overwrite? [y]es / [n]o / [r]ename: ", - output_path - ); - io::stderr().flush().ok(); - - let mut input = String::new(); - if io::stdin().read_line(&mut input).is_err() { - return None; - } - - let input = input.trim().to_lowercase(); - match input.as_str() { - "y" | "yes" => { - return Some(output_path.to_string()); - } - "n" | "no" => { - return None; - } - "r" | "rename" => { - // Generate a new filename - let new_name = generate_unique_filename(output_path); - eprintln!("Using: {}", new_name); - return Some(new_name); - } - _ => { - eprintln!("Please enter 'y', 'n', or 'r'"); - } - } - } -} - -/// Generate a unique filename by appending a number -fn generate_unique_filename(base_path: &str) -> String { - let path = Path::new(base_path); - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("duplicates"); - let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("json"); - let parent = path.parent().and_then(|p| p.to_str()).unwrap_or(""); - - for i in 1..1000 { - let new_name = if parent.is_empty() { - format!("{}_{}.{}", stem, i, ext) - } else { - format!("{}/{}_{}.{}", parent, stem, i, ext) - }; - - if !Path::new(&new_name).exists() { - return new_name; - } - } - - // Fallback with timestamp - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - if parent.is_empty() { - format!("{}_{}.{}", stem, timestamp, ext) - } else { - format!("{}/{}_{}.{}", parent, stem, timestamp, ext) - } -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index b11e299a8..000000000 --- a/src/config.rs +++ /dev/null @@ -1,38 +0,0 @@ -use clap::Args; - -/// Configuration for connecting to Trustify API -#[derive(Args, Clone)] -pub struct Config { - /// Trustify API URL (required) - #[arg(short = 'u', long = "url", env = "TRUSTIFY_URL")] - pub url: String, - - /// SSO URL for authentication - #[arg(long = "sso-url", env = "TRUSTIFY_SSO_URL")] - pub sso_url: Option, - - /// OAuth2 Client ID - #[arg(long = "client-id", env = "TRUSTIFY_CLIENT_ID")] - pub client_id: Option, - - /// OAuth2 Client Secret - #[arg(long = "client-secret", env = "TRUSTIFY_CLIENT_SECRET")] - pub client_secret: Option, -} - -impl Config { - /// Returns true if authentication credentials are configured - pub fn has_auth(&self) -> bool { - self.sso_url.is_some() && self.client_id.is_some() && self.client_secret.is_some() - } - - /// Returns the auth credentials if all are present - pub fn auth_credentials(&self) -> Option<(&str, &str, &str)> { - match (&self.sso_url, &self.client_id, &self.client_secret) { - (Some(sso), Some(id), Some(secret)) => { - Some((sso.as_str(), id.as_str(), secret.as_str())) - } - _ => None, - } - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 560c2946b..000000000 --- a/src/main.rs +++ /dev/null @@ -1,52 +0,0 @@ -mod api; -mod cli; -mod commands; -mod config; - -use std::process; - -use clap::Parser; - -use api::auth::AuthCredentials; -use api::ApiClient; -use cli::Cli; - -/// Runtime context containing config and API client -pub struct Context { - pub config: config::Config, - pub client: ApiClient, -} - -#[tokio::main] -async fn main() { - // Load .env file if present (silently ignore if not found) - let _ = dotenvy::dotenv(); - - let cli = Cli::parse(); - - // Build auth credentials and get initial token if configured - let (token, auth_credentials) = - if let Some((sso_url, client_id, client_secret)) = cli.config.auth_credentials() { - let creds = AuthCredentials::new(sso_url, client_id, client_secret); - - match creds.get_token().await { - Ok(token) => (Some(token), Some(creds)), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } - } else { - (None, None) - }; - - // Create API client with auth credentials for token refresh - let client = ApiClient::new(&cli.config.url, token, auth_credentials); - - let ctx = Context { - config: cli.config, - client, - }; - - cli.command.run(&ctx).await; -} From 68963dcd6715b814de566c7652ef1bf7d083a14a Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Mon, 9 Feb 2026 14:47:29 +0800 Subject: [PATCH 09/12] feat: Add unit tests for the command. --- etc/trustify-cli/src/api/test.rs | 135 +---------------------- etc/trustify-cli/src/commands/mod.rs | 3 + etc/trustify-cli/src/commands/test.rs | 153 ++++++++++++++++++++++++++ etc/trustify-cli/src/main.rs | 1 + etc/trustify-cli/src/utils.rs | 124 +++++++++++++++++++++ 5 files changed, 286 insertions(+), 130 deletions(-) create mode 100644 etc/trustify-cli/src/commands/test.rs create mode 100644 etc/trustify-cli/src/utils.rs diff --git a/etc/trustify-cli/src/api/test.rs b/etc/trustify-cli/src/api/test.rs index 570b8b704..e4f738c20 100644 --- a/etc/trustify-cli/src/api/test.rs +++ b/etc/trustify-cli/src/api/test.rs @@ -1,6 +1,6 @@ use super::client::{ApiClient, ApiError}; use super::sbom::{ListParams, delete_by_query, list}; -use serde_json::json; +use crate::utils; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -11,136 +11,11 @@ async fn create_test_client() -> (ApiClient, MockServer) { (client, mock_server) } -/// Helper function to create a mock response with 3 SBOM items -fn create_mock_sbom_response() -> serde_json::Value { - json!({ - "items": [ - { - "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", - "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.6", - "labels": { - "type": "spdx" - }, - "data_licenses": ["CC0-1.0"], - "published": "2024-09-17T11:31:02Z", - "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], - "suppliers": [], - "name": "MTV-2.6", - "number_of_packages": 5388, - "sha256": "sha256:c7caabdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565614", - "sha384": "sha384:7c5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed1", - "sha512": "sha512:411b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e391", - "size": 8941362, - "ingested": "2026-02-03T15:18:54.375162Z", - "described_by": [ - { - "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0419", - "name": "MTV-2.6", - "group": null, - "version": "2.6", - "purl": [], - "cpe": [ - "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el9:*", - "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el8:*" - ], - "licenses": [ - { - "license_name": "NOASSERTION", - "license_type": "declared" - }, - { - "license_name": "NOASSERTION", - "license_type": "concluded" - } - ], - "licenses_ref_mapping": [] - } - ] - }, - { - "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", - "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.7", - "labels": { - "type": "spdx" - }, - "data_licenses": ["MIT"], - "published": "2024-09-18T12:00:00Z", - "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], - "suppliers": [], - "name": "MTV-2.7", - "number_of_packages": 400, - "sha256": "sha256:d8ebdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565615", - "sha384": "sha384:8d5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed2", - "sha512": "sha512:522b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e392", - "size": 7500000, - "ingested": "2026-02-04T10:00:00.000000Z", - "described_by": [ - { - "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0420", - "name": "MTV-2.7", - "group": null, - "version": "2.7", - "purl": [], - "cpe": [ - "cpe:/a:redhat:migration_toolkit_virtualization:2.7:*:el9:*" - ], - "licenses": [ - { - "license_name": "MIT", - "license_type": "declared" - } - ], - "licenses_ref_mapping": [] - } - ] - }, - { - "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", - "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.8", - "labels": { - "type": "cyclonedx" - }, - "data_licenses": ["Apache-2.0"], - "published": "2024-09-19T13:00:00Z", - "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], - "suppliers": [], - "name": "MTV-2.8", - "number_of_packages": 250, - "sha256": "sha256:e9fbdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565616", - "sha384": "sha384:9e5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed3", - "sha512": "sha512:633b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e393", - "size": 6000000, - "ingested": "2026-02-05T08:00:00.000000Z", - "described_by": [ - { - "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0421", - "name": "MTV-2.8", - "group": null, - "version": "2.8", - "purl": [], - "cpe": [ - "cpe:/a:redhat:migration_toolkit_virtualization:2.8:*:el9:*" - ], - "licenses": [ - { - "license_name": "Apache-2.0", - "license_type": "declared" - } - ], - "licenses_ref_mapping": [] - } - ] - } - ], - "total": 3 - }) -} - #[tokio::test] async fn test_list_sboms_success() { let (client, mock_server) = create_test_client().await; - let mock_response = create_mock_sbom_response(); + let mock_response = utils::create_mock_sbom_response(); Mock::given(method("GET")) .and(path("/api/v2/sbom")) @@ -168,7 +43,7 @@ async fn test_list_sboms_success() { async fn test_delete_by_query_success() { let (client, mock_server) = create_test_client().await; - let mock_response = create_mock_sbom_response(); + let mock_response = utils::create_mock_sbom_response(); Mock::given(method("GET")) .and(path("/api/v2/sbom")) @@ -199,7 +74,7 @@ async fn test_delete_by_query_success() { async fn test_delete_by_query_dry_run() { let (client, mock_server) = create_test_client().await; - let mock_response = create_mock_sbom_response(); + let mock_response = utils::create_mock_sbom_response(); Mock::given(method("GET")) .and(path("/api/v2/sbom")) @@ -223,7 +98,7 @@ async fn test_delete_by_query_dry_run() { async fn test_delete_by_query_with_not_found_and_failed() { let (client, mock_server) = create_test_client().await; - let mock_response = create_mock_sbom_response(); + let mock_response = utils::create_mock_sbom_response(); Mock::given(method("GET")) .and(path("/api/v2/sbom")) diff --git a/etc/trustify-cli/src/commands/mod.rs b/etc/trustify-cli/src/commands/mod.rs index d279a6c9c..c3627cfc5 100644 --- a/etc/trustify-cli/src/commands/mod.rs +++ b/etc/trustify-cli/src/commands/mod.rs @@ -7,6 +7,9 @@ use crate::Context; pub use auth::AuthCommands; pub use sbom::SbomCommands; +#[cfg(test)] +mod test; + #[derive(Subcommand)] pub enum Commands { /// SBOM management commands diff --git a/etc/trustify-cli/src/commands/test.rs b/etc/trustify-cli/src/commands/test.rs new file mode 100644 index 000000000..d0b692fdc --- /dev/null +++ b/etc/trustify-cli/src/commands/test.rs @@ -0,0 +1,153 @@ +use crate::Context; +use crate::commands::sbom::SbomCommands; +use crate::utils; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[cfg(test)] +mod tests { + use super::*; + + async fn create_test_context() -> (Context, MockServer) { + let mock_server = MockServer::start().await; + let base_url = mock_server.uri(); + + let client = crate::ApiClient::new(&base_url, None, None); + let config = crate::config::Config { + url: base_url, + sso_url: None, + client_id: None, + client_secret: None, + }; + + (Context { config, client }, mock_server) + } + + #[tokio::test] + async fn test_delete_command_with_id_success() { + let (ctx, mock_server) = create_test_context().await; + + Mock::given(method("DELETE")) + .and(path("/api/v2/sbom/test-id-123")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&mock_server) + .await; + + let command = SbomCommands::Delete { + id: Some("test-id-123".to_string()), + query: None, + dry_run: false, + concurrency: 10, + limit: None, + }; + + command.run(&ctx).await; + } + + #[tokio::test] + async fn test_delete_by_query_success() { + let (ctx, mock_server) = create_test_context().await; + + let mock_response = utils::create_mock_sbom_response(); + + Mock::given(method("GET")) + .and(path("/api/v2/sbom")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path_regex(r"^/api/v2/sbom/urn:uuid:019c.*$")) + .respond_with(ResponseTemplate::new(204)) + .expect(3) + .mount(&mock_server) + .await; + + let command = SbomCommands::Delete { + id: None, + query: Some("name:test".to_string()), + dry_run: false, + concurrency: 2, + limit: None, + }; + + command.run(&ctx).await; + } + + #[tokio::test] + async fn test_delete_by_query_dry_run() { + let (ctx, mock_server) = create_test_context().await; + + let mock_response = utils::create_mock_sbom_response(); + + Mock::given(method("GET")) + .and(path("/api/v2/sbom")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .expect(1) + .mount(&mock_server) + .await; + + let command = SbomCommands::Delete { + id: None, + query: Some("name:test".to_string()), + dry_run: true, + concurrency: 2, + limit: None, + }; + + command.run(&ctx).await; + } + + #[tokio::test] + async fn test_delete_by_query_with_not_found_and_failed() { + let (ctx, mock_server) = create_test_context().await; + + let mock_response = utils::create_mock_sbom_response(); + + Mock::given(method("GET")) + .and(path("/api/v2/sbom")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", + )) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", + )) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&mock_server) + .await; + + Mock::given(method("DELETE")) + .and(path( + "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", + )) + .respond_with(ResponseTemplate::new(502)) + .expect(3) + .mount(&mock_server) + .await; + + let command = SbomCommands::Delete { + id: None, + query: Some("name:test".to_string()), + dry_run: false, + concurrency: 2, + limit: None, + }; + + command.run(&ctx).await; + } +} diff --git a/etc/trustify-cli/src/main.rs b/etc/trustify-cli/src/main.rs index 728cd674a..1bc8fd63b 100644 --- a/etc/trustify-cli/src/main.rs +++ b/etc/trustify-cli/src/main.rs @@ -2,6 +2,7 @@ mod api; mod cli; mod commands; mod config; +mod utils; use std::process; diff --git a/etc/trustify-cli/src/utils.rs b/etc/trustify-cli/src/utils.rs new file mode 100644 index 000000000..bceced1a0 --- /dev/null +++ b/etc/trustify-cli/src/utils.rs @@ -0,0 +1,124 @@ +#[cfg(test)] +pub fn create_mock_sbom_response() -> serde_json::Value { + serde_json::json!({ + "items": [ + { + "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", + "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.6", + "labels": { + "type": "spdx" + }, + "data_licenses": ["CC0-1.0"], + "published": "2024-09-17T11:31:02Z", + "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], + "suppliers": [], + "name": "MTV-2.6", + "number_of_packages": 5388, + "sha256": "sha256:c7caabdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565614", + "sha384": "sha384:7c5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed1", + "sha512": "sha512:411b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e391", + "size": 8941362, + "ingested": "2026-02-03T15:18:54.375162Z", + "described_by": [ + { + "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0419", + "name": "MTV-2.6", + "group": null, + "version": "2.6", + "purl": [], + "cpe": [ + "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el9:*", + "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el8:*" + ], + "licenses": [ + { + "license_name": "NOASSERTION", + "license_type": "declared" + }, + { + "license_name": "NOASSERTION", + "license_type": "concluded" + } + ], + "licenses_ref_mapping": [] + } + ] + }, + { + "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", + "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.7", + "labels": { + "type": "spdx" + }, + "data_licenses": ["MIT"], + "published": "2024-09-18T12:00:00Z", + "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], + "suppliers": [], + "name": "MTV-2.7", + "number_of_packages": 400, + "sha256": "sha256:d8ebdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565615", + "sha384": "sha384:8d5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed2", + "sha512": "sha512:522b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e392", + "size": 7500000, + "ingested": "2026-02-04T10:00:00.000000Z", + "described_by": [ + { + "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0420", + "name": "MTV-2.7", + "group": null, + "version": "2.7", + "purl": [], + "cpe": [ + "cpe:/a:redhat:migration_toolkit_virtualization:2.7:*:el9:*" + ], + "licenses": [ + { + "license_name": "MIT", + "license_type": "declared" + } + ], + "licenses_ref_mapping": [] + } + ] + }, + { + "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", + "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.8", + "labels": { + "type": "cyclonedx" + }, + "data_licenses": ["Apache-2.0"], + "published": "2024-09-19T13:00:00Z", + "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], + "suppliers": [], + "name": "MTV-2.8", + "number_of_packages": 250, + "sha256": "sha256:e9fbdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565616", + "sha384": "sha384:9e5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed3", + "sha512": "sha512:633b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e393", + "size": 6000000, + "ingested": "2026-02-05T08:00:00.000000Z", + "described_by": [ + { + "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0421", + "name": "MTV-2.8", + "group": null, + "version": "2.8", + "purl": [], + "cpe": [ + "cpe:/a:redhat:migration_toolkit_virtualization:2.8:*:el9:*" + ], + "licenses": [ + { + "license_name": "Apache-2.0", + "license_type": "declared" + } + ], + "licenses_ref_mapping": [] + } + ] + } + ], + "total": 3 + }) +} From 8d24c80a70bf131d0f856ced6f65e5e713cfbb3e Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Mon, 9 Feb 2026 18:52:07 +0800 Subject: [PATCH 10/12] =?UTF-8?q?chore:=20Refactored=20the=20command?= =?UTF-8?q?=E2=80=99s=20exception=20handling=20to=20make=20it=20simpler.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + etc/trustify-cli/Cargo.toml | 1 + etc/trustify-cli/src/commands/auth.rs | 16 +-- etc/trustify-cli/src/commands/mod.rs | 3 +- etc/trustify-cli/src/commands/sbom.rs | 155 +++++++++++--------------- etc/trustify-cli/src/commands/test.rs | 16 ++- etc/trustify-cli/src/main.rs | 17 +-- 7 files changed, 90 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b17e8442..602b0b64c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8185,6 +8185,7 @@ dependencies = [ name = "trustify-cli" version = "0.1.0" dependencies = [ + "anyhow", "clap", "dotenvy", "futures", diff --git a/etc/trustify-cli/Cargo.toml b/etc/trustify-cli/Cargo.toml index 20b5910e7..4cca360ec 100644 --- a/etc/trustify-cli/Cargo.toml +++ b/etc/trustify-cli/Cargo.toml @@ -8,6 +8,7 @@ name = "trustify" path = "src/main.rs" [dependencies] +anyhow = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } dotenvy = { workspace = true } futures = { workspace = true } diff --git a/etc/trustify-cli/src/commands/auth.rs b/etc/trustify-cli/src/commands/auth.rs index a9eb68002..3f01dbc26 100644 --- a/etc/trustify-cli/src/commands/auth.rs +++ b/etc/trustify-cli/src/commands/auth.rs @@ -1,8 +1,8 @@ use clap::Subcommand; +use std::process::ExitCode; use crate::Context; use crate::api::auth::AuthCredentials; -use std::process; #[derive(Subcommand)] pub enum AuthCommands { @@ -11,22 +11,18 @@ pub enum AuthCommands { } impl AuthCommands { - pub async fn run(&self, ctx: &Context) { + pub async fn run(&self, ctx: &Context) -> anyhow::Result { match self { AuthCommands::Token {} => match ctx.config.auth_credentials() { Some((token_url, client_id, client_secret)) => { let creds = AuthCredentials::new(token_url, client_id, client_secret); - match creds.get_token().await { - Ok(token) => println!("{}", token), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } + let token = creds.get_token().await?; + println!("{}", token); + Ok(ExitCode::SUCCESS) } None => { eprintln!("Error: SSO URL, client ID, and client secret are all required"); - process::exit(1); + Err(anyhow::anyhow!("Missing authentication credentials")) } }, } diff --git a/etc/trustify-cli/src/commands/mod.rs b/etc/trustify-cli/src/commands/mod.rs index c3627cfc5..15bf6491c 100644 --- a/etc/trustify-cli/src/commands/mod.rs +++ b/etc/trustify-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod sbom; use clap::Subcommand; +use std::process::ExitCode; use crate::Context; pub use auth::AuthCommands; @@ -26,7 +27,7 @@ pub enum Commands { } impl Commands { - pub async fn run(&self, ctx: &Context) { + pub async fn run(&self, ctx: &Context) -> anyhow::Result { match self { Commands::Sbom { command } => command.run(ctx).await, Commands::Auth { command } => command.run(ctx).await, diff --git a/etc/trustify-cli/src/commands/sbom.rs b/etc/trustify-cli/src/commands/sbom.rs index 43c0b18b9..d32bc4cdb 100644 --- a/etc/trustify-cli/src/commands/sbom.rs +++ b/etc/trustify-cli/src/commands/sbom.rs @@ -1,6 +1,6 @@ use std::io::{self, Write}; use std::path::Path; -use std::process; +use std::process::ExitCode; use clap::{Subcommand, ValueEnum}; use serde_json::Value; @@ -110,18 +110,14 @@ pub enum SbomCommands { } impl SbomCommands { - pub async fn run(&self, ctx: &Context) { + pub async fn run(&self, ctx: &Context) -> anyhow::Result { match self { - SbomCommands::Duplicates { command } => { - command.run(ctx).await; + SbomCommands::Duplicates { command } => command.run(ctx).await, + SbomCommands::Get { id } => { + let json = sbom_api::get(&ctx.client, id).await?; + println!("{}", json); + Ok(ExitCode::SUCCESS) } - SbomCommands::Get { id } => match sbom_api::get(&ctx.client, id).await { - Ok(json) => println!("{}", json), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - }, SbomCommands::List { query, limit, @@ -135,15 +131,9 @@ impl SbomCommands { offset: *offset, sort: sort.clone(), }; - match sbom_api::list(&ctx.client, ¶ms).await { - Ok(json) => { - format_list_output(&json, format); - } - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } + let json = sbom_api::list(&ctx.client, ¶ms).await?; + format_list_output(&json, format)?; + Ok(ExitCode::SUCCESS) } SbomCommands::Delete { id, @@ -153,55 +143,43 @@ impl SbomCommands { limit, } => { if let Some(i) = id { - match sbom_api::delete(&ctx.client, i).await { - Ok(_) => println!("Deleted SBOM ID: {}", i), - Err(e) => { - eprintln!("Error deleting ID {}: {}", i, e); - process::exit(1); - } - } + sbom_api::delete(&ctx.client, i).await?; + println!("Deleted SBOM ID: {}", i); } if let Some(q) = query { - match sbom_api::delete_by_query( + let delete_result = sbom_api::delete_by_query( &ctx.client, Some(q.as_str()), *dry_run, *concurrency, *limit, ) - .await - { - Ok(result) => { - if *dry_run { - println!("[DRY-RUN] Would delete {} SBOM(s)", result.total); - } else { - let mut msg = format!("Deleted {} SBOM(s)", result.deleted); - if result.skipped > 0 { - msg.push_str(&format!( - ", {} skipped (not found)", - result.skipped - )); - } - if result.failed > 0 { - msg.push_str(&format!(", {} failed", result.failed)); - } - msg.push_str(&format!(" out of {} total", result.total)); - println!("{}", msg); - } + .await?; + if *dry_run { + println!("[DRY-RUN] Would delete {} SBOM(s)", delete_result.total); + } else { + let mut msg = format!("Deleted {} SBOM(s)", delete_result.deleted); + if delete_result.skipped > 0 { + msg.push_str(&format!( + ", {} skipped (not found)", + delete_result.skipped + )); } - Err(e) => { - eprintln!("Error deleting SBOMs: {}", e); - process::exit(1); + if delete_result.failed > 0 { + msg.push_str(&format!(", {} failed", delete_result.failed)); } + msg.push_str(&format!(" out of {} total", delete_result.total)); + println!("{}", msg); } } + Ok(ExitCode::SUCCESS) } } } } impl DuplicatesCommands { - pub async fn run(&self, ctx: &Context) { + pub async fn run(&self, ctx: &Context) -> anyhow::Result { match self { DuplicatesCommands::Find { batch_size, @@ -217,7 +195,7 @@ impl DuplicatesCommands { let final_output = check_output_file(output_path); if final_output.is_none() { eprintln!("Operation cancelled."); - process::exit(0); + return Ok(ExitCode::SUCCESS); } let final_output = final_output.unwrap_or("duplicates.json".to_string()); @@ -225,24 +203,17 @@ impl DuplicatesCommands { batch_size: *batch_size, concurrency: *concurrency, }; - match sbom_api::find_duplicates(&ctx.client, ¶ms, &Some(final_output.clone())) - .await - { - Ok(groups) => { - let total_duplicates: usize = - groups.iter().map(|g| g.duplicates.len()).sum(); - println!( - "Found {} document(s) with {} duplicate(s). Saved to {}", - groups.len(), - total_duplicates, - final_output - ); - } - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } + let groups = + sbom_api::find_duplicates(&ctx.client, ¶ms, &Some(final_output.clone())) + .await?; + let total_duplicates: usize = groups.iter().map(|g| g.duplicates.len()).sum(); + println!( + "Found {} document(s) with {} duplicate(s). Saved to {}", + groups.len(), + total_duplicates, + final_output + ); + Ok(ExitCode::SUCCESS) } DuplicatesCommands::Delete { input, @@ -254,41 +225,35 @@ impl DuplicatesCommands { .map(|s| s.as_str()) .unwrap_or("duplicates.json"); - match sbom_api::delete_duplicates(&ctx.client, input_path, *concurrency, *dry_run) - .await - { - Ok(result) => { - if *dry_run { - println!("[DRY-RUN] Would delete {} duplicate(s)", result.total); - } else { - let mut msg = format!("Deleted {} duplicate(s)", result.deleted); - if result.skipped > 0 { - msg.push_str(&format!(", {} skipped (not found)", result.skipped)); - } - if result.failed > 0 { - msg.push_str(&format!(", {} failed", result.failed)); - } - msg.push_str(&format!(" out of {} total", result.total)); - println!("{}", msg); - } + let result = + sbom_api::delete_duplicates(&ctx.client, input_path, *concurrency, *dry_run) + .await?; + if *dry_run { + println!("[DRY-RUN] Would delete {} duplicate(s)", result.total); + } else { + let mut msg = format!("Deleted {} duplicate(s)", result.deleted); + if result.skipped > 0 { + msg.push_str(&format!(", {} skipped (not found)", result.skipped)); } - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); + if result.failed > 0 { + msg.push_str(&format!(", {} failed", result.failed)); } + msg.push_str(&format!(" out of {} total", result.total)); + println!("{}", msg); } + Ok(ExitCode::SUCCESS) } } } } /// Format and print list output based on the specified format -fn format_list_output(json: &str, format: &ListFormat) { +fn format_list_output(json: &str, format: &ListFormat) -> anyhow::Result { let parsed: Value = match serde_json::from_str(json) { Ok(v) => v, Err(e) => { eprintln!("Error parsing response: {}", e); - process::exit(1); + return Err(e.into()); } }; @@ -298,13 +263,14 @@ fn format_list_output(json: &str, format: &ListFormat) { None => { // If no items array, just print the raw JSON println!("{}", json); - return; + return Ok(ExitCode::SUCCESS); } }; match format { ListFormat::Full => { println!("{}", json); + Ok(ExitCode::SUCCESS) } ListFormat::Id => { for item in items { @@ -312,6 +278,7 @@ fn format_list_output(json: &str, format: &ListFormat) { println!("{}", id); } } + Ok(ExitCode::SUCCESS) } ListFormat::Name => { let result: Vec = items @@ -325,6 +292,7 @@ fn format_list_output(json: &str, format: &ListFormat) { }) .collect(); println!("{}", serde_json::to_string(&result).unwrap_or_default()); + Ok(ExitCode::SUCCESS) } ListFormat::Short => { let result: Vec = items @@ -341,6 +309,7 @@ fn format_list_output(json: &str, format: &ListFormat) { }) .collect(); println!("{}", serde_json::to_string(&result).unwrap_or_default()); + Ok(ExitCode::SUCCESS) } } } diff --git a/etc/trustify-cli/src/commands/test.rs b/etc/trustify-cli/src/commands/test.rs index d0b692fdc..96038492d 100644 --- a/etc/trustify-cli/src/commands/test.rs +++ b/etc/trustify-cli/src/commands/test.rs @@ -1,6 +1,7 @@ use crate::Context; use crate::commands::sbom::SbomCommands; use crate::utils; +use std::process::ExitCode; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -42,7 +43,8 @@ mod tests { limit: None, }; - command.run(&ctx).await; + let result = command.run(&ctx).await; + assert!(result.is_ok()); } #[tokio::test] @@ -73,7 +75,9 @@ mod tests { limit: None, }; - command.run(&ctx).await; + let result = command.run(&ctx).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), ExitCode::SUCCESS); } #[tokio::test] @@ -97,7 +101,9 @@ mod tests { limit: None, }; - command.run(&ctx).await; + let result = command.run(&ctx).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), ExitCode::SUCCESS); } #[tokio::test] @@ -148,6 +154,8 @@ mod tests { limit: None, }; - command.run(&ctx).await; + let result = command.run(&ctx).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), ExitCode::SUCCESS); } } diff --git a/etc/trustify-cli/src/main.rs b/etc/trustify-cli/src/main.rs index 1bc8fd63b..72a7e842a 100644 --- a/etc/trustify-cli/src/main.rs +++ b/etc/trustify-cli/src/main.rs @@ -4,8 +4,9 @@ mod commands; mod config; mod utils; -use std::process; +use std::process::ExitCode; +use anyhow::Result; use clap::Parser; use api::ApiClient; @@ -19,7 +20,7 @@ pub struct Context { } #[tokio::main] -async fn main() { +async fn main() -> Result { // Load .env file if present (silently ignore if not found) let _ = dotenvy::dotenv(); @@ -29,14 +30,8 @@ async fn main() { let (token, auth_credentials) = if let Some((sso_url, client_id, client_secret)) = cli.config.auth_credentials() { let creds = AuthCredentials::new(sso_url, client_id, client_secret); - - match creds.get_token().await { - Ok(token) => (Some(token), Some(creds)), - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); - } - } + let token = creds.get_token().await?; + (Some(token), Some(creds)) } else { (None, None) }; @@ -49,5 +44,5 @@ async fn main() { client, }; - cli.command.run(&ctx).await; + cli.command.run(&ctx).await } From f94214478806680fb32c315ff2b47e47e8535692 Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Tue, 10 Feb 2026 14:32:57 +0800 Subject: [PATCH 11/12] chore: remove mockup test --- Cargo.lock | 73 - Cargo.toml | 1 - etc/trustify-cli/Cargo.lock | 2070 ------------------------- etc/trustify-cli/Cargo.toml | 4 - etc/trustify-cli/README.md | 13 +- etc/trustify-cli/src/api/mod.rs | 3 - etc/trustify-cli/src/api/test.rs | 146 -- etc/trustify-cli/src/commands/mod.rs | 3 - etc/trustify-cli/src/commands/test.rs | 161 -- etc/trustify-cli/src/main.rs | 1 - etc/trustify-cli/src/utils.rs | 124 -- 11 files changed, 1 insertion(+), 2598 deletions(-) delete mode 100644 etc/trustify-cli/Cargo.lock delete mode 100644 etc/trustify-cli/src/api/test.rs delete mode 100644 etc/trustify-cli/src/commands/test.rs delete mode 100644 etc/trustify-cli/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 602b0b64c..1a3112ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2395,12 +2395,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "downcast" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - [[package]] name = "dunce" version = "1.0.5" @@ -2747,12 +2741,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fragile" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" - [[package]] name = "fs_extra" version = "1.3.0" @@ -4337,33 +4325,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mockall" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" -dependencies = [ - "cfg-if", - "downcast", - "fragile", - "lazy_static", - "mockall_derive", - "predicates", - "predicates-tree", -] - -[[package]] -name = "mockall_derive" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "moka" version = "0.12.14" @@ -5412,32 +5373,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "predicates" -version = "3.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" -dependencies = [ - "anstyle", - "predicates-core", -] - -[[package]] -name = "predicates-core" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" - -[[package]] -name = "predicates-tree" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" -dependencies = [ - "predicates-core", - "termtree", -] - [[package]] name = "pretty_assertions" version = "1.4.1" @@ -7661,12 +7596,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - [[package]] name = "test-context" version = "0.5.4" @@ -8190,13 +8119,11 @@ dependencies = [ "dotenvy", "futures", "indicatif", - "mockall", "reqwest 0.13.2", "serde", "serde_json", "thiserror 2.0.18", "tokio", - "wiremock", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9787ca331..6aa3067cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,6 @@ libz-sys = "*" log = "0.4.19" mime = "0.3.17" moka = "0.12.10" -mockall = "0.12" native-tls = "0.2" num-traits = "0.2" oci-client = "0.16.0" diff --git a/etc/trustify-cli/Cargo.lock b/etc/trustify-cli/Cargo.lock deleted file mode 100644 index 315dd1fb3..000000000 --- a/etc/trustify-cli/Cargo.lock +++ /dev/null @@ -1,2070 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "aws-lc-rs" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "cc" -version = "1.2.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "clap" -version = "4.5.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "console" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.61.2", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "indicatif" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openssl-probe" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "portable-atomic" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.17", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.17", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "reqwest" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl 2.0.17", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "trustify-cli" -version = "0.1.0" -dependencies = [ - "clap", - "dotenvy", - "futures", - "indicatif", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" diff --git a/etc/trustify-cli/Cargo.toml b/etc/trustify-cli/Cargo.toml index 4cca360ec..5d363d3bf 100644 --- a/etc/trustify-cli/Cargo.toml +++ b/etc/trustify-cli/Cargo.toml @@ -19,7 +19,3 @@ serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } -[dev-dependencies] -mockall = { workspace = true } -wiremock = { workspace = true } - diff --git a/etc/trustify-cli/README.md b/etc/trustify-cli/README.md index 5a6ec0e23..46aba5bc2 100644 --- a/etc/trustify-cli/README.md +++ b/etc/trustify-cli/README.md @@ -52,7 +52,7 @@ trustify sbom duplicates delete ```bash # Clone the repository -git clone https://github.com/ruromero/trustify-cli.git +git clone https://github.com/guacsec/trustify-cli.git cd trustify-cli # Build @@ -61,17 +61,6 @@ cargo build --release # The binary will be at ./target/release/trustify ``` -### Using Docker - -```bash -# Use your .env file with the container -docker run --rm --env-file .env ghcr.io/ruromero/trustify-cli sbom list - -# For commands that write files, mount a volume -docker run --rm --env-file .env -v $(pwd):/data \ - ghcr.io/ruromero/trustify-cli sbom duplicates find --output /data/duplicates.json -``` - ## Configuration Create a `.env` file in your working directory: diff --git a/etc/trustify-cli/src/api/mod.rs b/etc/trustify-cli/src/api/mod.rs index 8e4c0ef7f..0cb893a05 100644 --- a/etc/trustify-cli/src/api/mod.rs +++ b/etc/trustify-cli/src/api/mod.rs @@ -2,6 +2,3 @@ pub mod auth; pub mod client; pub mod sbom; pub use client::ApiClient; - -#[cfg(test)] -mod test; diff --git a/etc/trustify-cli/src/api/test.rs b/etc/trustify-cli/src/api/test.rs deleted file mode 100644 index e4f738c20..000000000 --- a/etc/trustify-cli/src/api/test.rs +++ /dev/null @@ -1,146 +0,0 @@ -use super::client::{ApiClient, ApiError}; -use super::sbom::{ListParams, delete_by_query, list}; -use crate::utils; -use wiremock::matchers::{method, path, path_regex}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -/// Helper function to create a test client connected to a mock server -async fn create_test_client() -> (ApiClient, MockServer) { - let mock_server = MockServer::start().await; - let client = ApiClient::new(&mock_server.uri(), None, None); - (client, mock_server) -} - -#[tokio::test] -async fn test_list_sboms_success() { - let (client, mock_server) = create_test_client().await; - - let mock_response = utils::create_mock_sbom_response(); - - Mock::given(method("GET")) - .and(path("/api/v2/sbom")) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - let params = ListParams { - q: None, - limit: Some(10), - offset: Some(0), - sort: None, - }; - - let result = list(&client, ¶ms).await; - - assert!(result.is_ok()); - let response_json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(response_json["total"], 3); - assert_eq!(response_json["items"].as_array().unwrap().len(), 3); -} - -#[tokio::test] -async fn test_delete_by_query_success() { - let (client, mock_server) = create_test_client().await; - - let mock_response = utils::create_mock_sbom_response(); - - Mock::given(method("GET")) - .and(path("/api/v2/sbom")) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path_regex(r"^/api/v2/sbom/urn:uuid:019c.*$")) - .respond_with(ResponseTemplate::new(204)) - .expect(3) - .mount(&mock_server) - .await; - - let result: Result = - delete_by_query(&client, Some("name:test"), false, 2, None).await; - - assert!(result.is_ok()); - let delete_result = result.unwrap(); - assert_eq!(delete_result.total, 3); - assert_eq!(delete_result.deleted, 3); - assert_eq!(delete_result.skipped, 0); - assert_eq!(delete_result.failed, 0); -} - -#[tokio::test] -async fn test_delete_by_query_dry_run() { - let (client, mock_server) = create_test_client().await; - - let mock_response = utils::create_mock_sbom_response(); - - Mock::given(method("GET")) - .and(path("/api/v2/sbom")) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - let result: Result = - delete_by_query(&client, Some("name:test"), true, 2, None).await; - - assert!(result.is_ok()); - let delete_result = result.unwrap(); - assert_eq!(delete_result.total, 3); - assert_eq!(delete_result.deleted, 0); - assert_eq!(delete_result.skipped, 0); - assert_eq!(delete_result.failed, 0); -} - -#[tokio::test] -async fn test_delete_by_query_with_not_found_and_failed() { - let (client, mock_server) = create_test_client().await; - - let mock_response = utils::create_mock_sbom_response(); - - Mock::given(method("GET")) - .and(path("/api/v2/sbom")) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path( - "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", - )) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path( - "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", - )) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path( - "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", - )) - .respond_with(ResponseTemplate::new(502)) - .expect(3) - .mount(&mock_server) - .await; - - let result: Result = - delete_by_query(&client, Some("name:test"), false, 2, None).await; - - assert!(result.is_ok()); - let delete_result = result.unwrap(); - assert_eq!(delete_result.total, 3); - assert_eq!(delete_result.deleted, 1); - assert_eq!(delete_result.skipped, 1); - assert_eq!(delete_result.failed, 1); -} diff --git a/etc/trustify-cli/src/commands/mod.rs b/etc/trustify-cli/src/commands/mod.rs index 15bf6491c..3790d3ad9 100644 --- a/etc/trustify-cli/src/commands/mod.rs +++ b/etc/trustify-cli/src/commands/mod.rs @@ -8,9 +8,6 @@ use crate::Context; pub use auth::AuthCommands; pub use sbom::SbomCommands; -#[cfg(test)] -mod test; - #[derive(Subcommand)] pub enum Commands { /// SBOM management commands diff --git a/etc/trustify-cli/src/commands/test.rs b/etc/trustify-cli/src/commands/test.rs deleted file mode 100644 index 96038492d..000000000 --- a/etc/trustify-cli/src/commands/test.rs +++ /dev/null @@ -1,161 +0,0 @@ -use crate::Context; -use crate::commands::sbom::SbomCommands; -use crate::utils; -use std::process::ExitCode; -use wiremock::matchers::{method, path, path_regex}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -#[cfg(test)] -mod tests { - use super::*; - - async fn create_test_context() -> (Context, MockServer) { - let mock_server = MockServer::start().await; - let base_url = mock_server.uri(); - - let client = crate::ApiClient::new(&base_url, None, None); - let config = crate::config::Config { - url: base_url, - sso_url: None, - client_id: None, - client_secret: None, - }; - - (Context { config, client }, mock_server) - } - - #[tokio::test] - async fn test_delete_command_with_id_success() { - let (ctx, mock_server) = create_test_context().await; - - Mock::given(method("DELETE")) - .and(path("/api/v2/sbom/test-id-123")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&mock_server) - .await; - - let command = SbomCommands::Delete { - id: Some("test-id-123".to_string()), - query: None, - dry_run: false, - concurrency: 10, - limit: None, - }; - - let result = command.run(&ctx).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_delete_by_query_success() { - let (ctx, mock_server) = create_test_context().await; - - let mock_response = utils::create_mock_sbom_response(); - - Mock::given(method("GET")) - .and(path("/api/v2/sbom")) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path_regex(r"^/api/v2/sbom/urn:uuid:019c.*$")) - .respond_with(ResponseTemplate::new(204)) - .expect(3) - .mount(&mock_server) - .await; - - let command = SbomCommands::Delete { - id: None, - query: Some("name:test".to_string()), - dry_run: false, - concurrency: 2, - limit: None, - }; - - let result = command.run(&ctx).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), ExitCode::SUCCESS); - } - - #[tokio::test] - async fn test_delete_by_query_dry_run() { - let (ctx, mock_server) = create_test_context().await; - - let mock_response = utils::create_mock_sbom_response(); - - Mock::given(method("GET")) - .and(path("/api/v2/sbom")) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - let command = SbomCommands::Delete { - id: None, - query: Some("name:test".to_string()), - dry_run: true, - concurrency: 2, - limit: None, - }; - - let result = command.run(&ctx).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), ExitCode::SUCCESS); - } - - #[tokio::test] - async fn test_delete_by_query_with_not_found_and_failed() { - let (ctx, mock_server) = create_test_context().await; - - let mock_response = utils::create_mock_sbom_response(); - - Mock::given(method("GET")) - .and(path("/api/v2/sbom")) - .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path( - "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", - )) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path( - "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", - )) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path( - "/api/v2/sbom/urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", - )) - .respond_with(ResponseTemplate::new(502)) - .expect(3) - .mount(&mock_server) - .await; - - let command = SbomCommands::Delete { - id: None, - query: Some("name:test".to_string()), - dry_run: false, - concurrency: 2, - limit: None, - }; - - let result = command.run(&ctx).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), ExitCode::SUCCESS); - } -} diff --git a/etc/trustify-cli/src/main.rs b/etc/trustify-cli/src/main.rs index 72a7e842a..d065de4f0 100644 --- a/etc/trustify-cli/src/main.rs +++ b/etc/trustify-cli/src/main.rs @@ -2,7 +2,6 @@ mod api; mod cli; mod commands; mod config; -mod utils; use std::process::ExitCode; diff --git a/etc/trustify-cli/src/utils.rs b/etc/trustify-cli/src/utils.rs deleted file mode 100644 index bceced1a0..000000000 --- a/etc/trustify-cli/src/utils.rs +++ /dev/null @@ -1,124 +0,0 @@ -#[cfg(test)] -pub fn create_mock_sbom_response() -> serde_json::Value { - serde_json::json!({ - "items": [ - { - "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faeb", - "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.6", - "labels": { - "type": "spdx" - }, - "data_licenses": ["CC0-1.0"], - "published": "2024-09-17T11:31:02Z", - "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], - "suppliers": [], - "name": "MTV-2.6", - "number_of_packages": 5388, - "sha256": "sha256:c7caabdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565614", - "sha384": "sha384:7c5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed1", - "sha512": "sha512:411b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e391", - "size": 8941362, - "ingested": "2026-02-03T15:18:54.375162Z", - "described_by": [ - { - "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0419", - "name": "MTV-2.6", - "group": null, - "version": "2.6", - "purl": [], - "cpe": [ - "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el9:*", - "cpe:/a:redhat:migration_toolkit_virtualization:2.6:*:el8:*" - ], - "licenses": [ - { - "license_name": "NOASSERTION", - "license_type": "declared" - }, - { - "license_name": "NOASSERTION", - "license_type": "concluded" - } - ], - "licenses_ref_mapping": [] - } - ] - }, - { - "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faec", - "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.7", - "labels": { - "type": "spdx" - }, - "data_licenses": ["MIT"], - "published": "2024-09-18T12:00:00Z", - "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], - "suppliers": [], - "name": "MTV-2.7", - "number_of_packages": 400, - "sha256": "sha256:d8ebdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565615", - "sha384": "sha384:8d5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed2", - "sha512": "sha512:522b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e392", - "size": 7500000, - "ingested": "2026-02-04T10:00:00.000000Z", - "described_by": [ - { - "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0420", - "name": "MTV-2.7", - "group": null, - "version": "2.7", - "purl": [], - "cpe": [ - "cpe:/a:redhat:migration_toolkit_virtualization:2.7:*:el9:*" - ], - "licenses": [ - { - "license_name": "MIT", - "license_type": "declared" - } - ], - "licenses_ref_mapping": [] - } - ] - }, - { - "id": "urn:uuid:019c2415-ccb6-7081-a80a-9bd15137faed", - "document_id": "https://access.redhat.com/security/data/sbom/spdx/MTV-2.8", - "labels": { - "type": "cyclonedx" - }, - "data_licenses": ["Apache-2.0"], - "published": "2024-09-19T13:00:00Z", - "authors": ["Organization: Red Hat Product Security (secalert@redhat.com)"], - "suppliers": [], - "name": "MTV-2.8", - "number_of_packages": 250, - "sha256": "sha256:e9fbdc60d456efbed6e3634c9969c4ca04b41216e7e951bfd44510bf565616", - "sha384": "sha384:9e5452eee045931d53640986bf8d7e7ba7f5ffcc3956563d3e46571e5759ca7f8f64c4ba7a6c1ac316ead118d3ea8ed3", - "sha512": "sha512:633b78bf380851082064da58a465620b707dec0f65a7c84cd2c563862d87b61d14aa4b35657ce91d9f60eb387869627a92e3eb23d35423b843df2b89c1d7e393", - "size": 6000000, - "ingested": "2026-02-05T08:00:00.000000Z", - "described_by": [ - { - "id": "SPDXRef-018cf2a3-f3dd-4100-b0c0-2f1fe97d0421", - "name": "MTV-2.8", - "group": null, - "version": "2.8", - "purl": [], - "cpe": [ - "cpe:/a:redhat:migration_toolkit_virtualization:2.8:*:el9:*" - ], - "licenses": [ - { - "license_name": "Apache-2.0", - "license_type": "declared" - } - ], - "licenses_ref_mapping": [] - } - ] - } - ], - "total": 3 - }) -} From 57524c5f35a7b179bd558f4efc3a79463d2186a6 Mon Sep 17 00:00:00 2001 From: "bxf12315@gmail.com" Date: Tue, 10 Feb 2026 19:36:24 +0800 Subject: [PATCH 12/12] chore: make AI review happy --- .dockerignore | 7 +++++++ .gitignore | 2 +- Cargo.lock | 1 - Cargo.toml | 4 ++-- LICENSE | 2 +- README.md | 2 +- etc/trustify-cli/Cargo.toml | 2 +- etc/trustify-cli/src/api/client.rs | 4 ++-- etc/trustify-cli/src/commands/sbom.rs | 8 ++++++-- 9 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..2ac0bebaa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.idea +.DS_Store +/data +.trustify +/target +/.dockerignore +/Containerfile diff --git a/.gitignore b/.gitignore index 0c74bc352..1c817d349 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ /dump.sql .ai_history.txt /vendor/ -/vendor.toml \ No newline at end of file +/vendor.toml diff --git a/Cargo.lock b/Cargo.lock index 1a3112ddb..0c7e22fe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5817,7 +5817,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2 0.4.13", diff --git a/Cargo.toml b/Cargo.toml index 6aa3067cb..42512e070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,7 +110,7 @@ petgraph = { version = "0.8.0", features = ["serde-1"] } quick-xml = "0.39.0" rand = "0.10.0" regex = "1.10.3" -reqwest = { version = "0.13.1", features = ["blocking", "form", "json", "query"] } +reqwest = "0.13" ring = "0.17.8" roxmltree = "0.21.1" rstest = "0.26.1" @@ -213,4 +213,4 @@ csaf = { git = "https://github.com/scm-rs/csaf-rs", branch = "main" } #osv = { git = "https://github.com/ctron/osv", branch = "feature/drop_deps_1" } # required due to https://github.com/doubleopen-project/spdx-rs/pull/35 -spdx-rs = { git = "https://github.com/ctron/spdx-rs", branch = "feature/add_alias_2" } \ No newline at end of file +spdx-rs = { git = "https://github.com/ctron/spdx-rs", branch = "feature/add_alias_2" } diff --git a/LICENSE b/LICENSE index f49a4e16e..261eeb9e9 100644 --- a/LICENSE +++ b/LICENSE @@ -198,4 +198,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. diff --git a/README.md b/README.md index 778f5b69b..3601c9157 100644 --- a/README.md +++ b/README.md @@ -279,4 +279,4 @@ CPE (Product?) and/or pURLs described by the SBOM ## Related links * [Trustify scale test results](https://guacsec.github.io/trustify-scale-test-runs/) -* [Trustify benchmark results](https://guacsec.github.io/trustify/dev/bench/) \ No newline at end of file +* [Trustify benchmark results](https://guacsec.github.io/trustify/dev/bench/) diff --git a/etc/trustify-cli/Cargo.toml b/etc/trustify-cli/Cargo.toml index 5d363d3bf..61c04894d 100644 --- a/etc/trustify-cli/Cargo.toml +++ b/etc/trustify-cli/Cargo.toml @@ -13,7 +13,7 @@ clap = { workspace = true, features = ["derive", "env"] } dotenvy = { workspace = true } futures = { workspace = true } indicatif = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "json"] } +reqwest = { workspace = true, features = ["form"] } serde = { workspace = true , features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/etc/trustify-cli/src/api/client.rs b/etc/trustify-cli/src/api/client.rs index ba52e0b28..f3530f480 100644 --- a/etc/trustify-cli/src/api/client.rs +++ b/etc/trustify-cli/src/api/client.rs @@ -18,7 +18,7 @@ pub enum ApiError { #[error("HTTP {0}: {1}")] HttpError(u16, String), - #[error("HTTP 404: Resource not found")] + #[error("HTTP 404: Resource not found {0}")] NotFound(String), #[error("HTTP 401: Please check your authentication credentials")] @@ -43,7 +43,7 @@ pub enum ApiError { impl From for ApiError { fn from(e: reqwest::Error) -> Self { if e.is_timeout() { - ApiError::Timeout(0) // 0 indicates network-level timeout (no HTTP response) + ApiError::Timeout(e.status().unwrap_or(StatusCode::REQUEST_TIMEOUT).as_u16()) // 0 indicates network-level timeout (no HTTP response) } else if e.is_connect() { ApiError::NetworkError(format!("Connection failed: {}", e)) } else if e.is_request() { diff --git a/etc/trustify-cli/src/commands/sbom.rs b/etc/trustify-cli/src/commands/sbom.rs index d32bc4cdb..9bba88550 100644 --- a/etc/trustify-cli/src/commands/sbom.rs +++ b/etc/trustify-cli/src/commands/sbom.rs @@ -291,7 +291,9 @@ fn format_list_output(json: &str, format: &ListFormat) -> anyhow::Result { @@ -308,7 +310,9 @@ fn format_list_output(json: &str, format: &ListFormat) -> anyhow::Result