diff --git a/Cargo.lock b/Cargo.lock index 236704ff4..0c7e22fe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8109,6 +8109,22 @@ dependencies = [ "utoipa-swagger-ui", ] +[[package]] +name = "trustify-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "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 0a1b3ae48..42512e070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "test-context", "trustd", "xtask", + "etc/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" diff --git a/etc/trustify-cli/Cargo.toml b/etc/trustify-cli/Cargo.toml new file mode 100644 index 000000000..61c04894d --- /dev/null +++ b/etc/trustify-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "trustify-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "trustify" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +dotenvy = { workspace = true } +futures = { workspace = true } +indicatif = { workspace = true } +reqwest = { workspace = true, features = ["form"] } +serde = { workspace = true , features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } + diff --git a/etc/trustify-cli/LICENSE b/etc/trustify-cli/LICENSE new file mode 100644 index 000000000..b38499aae --- /dev/null +++ b/etc/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/etc/trustify-cli/README.md b/etc/trustify-cli/README.md new file mode 100644 index 000000000..46aba5bc2 --- /dev/null +++ b/etc/trustify-cli/README.md @@ -0,0 +1,177 @@ +# 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/guacsec/trustify-cli.git +cd trustify-cli + +# Build +cargo build --release + +# The binary will be at ./target/release/trustify +``` + +## 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/etc/trustify-cli/src/api/auth.rs b/etc/trustify-cli/src/api/auth.rs new file mode 100644 index 000000000..1fb6bf0b6 --- /dev/null +++ b/etc/trustify-cli/src/api/auth.rs @@ -0,0 +1,110 @@ +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/etc/trustify-cli/src/api/client.rs b/etc/trustify-cli/src/api/client.rs new file mode 100644 index 000000000..f3530f480 --- /dev/null +++ b/etc/trustify-cli/src/api/client.rs @@ -0,0 +1,236 @@ +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; + +#[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 {0}")] + 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), + + #[error("Template error: {0}")] + TemplateError(String), +} + +impl From for ApiError { + fn from(e: reqwest::Error) -> Self { + if e.is_timeout() { + 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() { + ApiError::NetworkError(format!("Request error: {}", e)) + } else { + ApiError::NetworkError(e.to_string()) + } + } +} + +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; + +/// 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/etc/trustify-cli/src/api/mod.rs b/etc/trustify-cli/src/api/mod.rs new file mode 100644 index 000000000..0cb893a05 --- /dev/null +++ b/etc/trustify-cli/src/api/mod.rs @@ -0,0 +1,4 @@ +pub mod auth; +pub mod client; +pub mod sbom; +pub use client::ApiClient; diff --git a/etc/trustify-cli/src/api/sbom.rs b/etc/trustify-cli/src/api/sbom.rs new file mode 100644 index 000000000..fd571a502 --- /dev/null +++ b/etc/trustify-cli/src/api/sbom.rs @@ -0,0 +1,503 @@ +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, Write}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; + +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}%)")? + .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(()) +} + +/// 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 DeleteResult { + pub deleted: u32, + pub skipped: u32, + pub failed: u32, + pub total: u32, +} + +/// Entry to delete with its document_id for logging +#[derive(Clone)] +pub struct DeleteEntry { + id: String, + document_id: String, +} + +/// 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!( + "Input file not found: {}", + input_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)))?; + + let entries: Vec = groups + .iter() + .flat_map(|group| { + group.duplicates.iter().map(|id| DeleteEntry { + id: id.clone(), + document_id: group.document_id.clone(), + }) + }) + .collect(); + + Ok(entries) +} + +/// 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 + ); + + let progress = ProgressBar::new(total as u64); + progress.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}")? + .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(_)) => { + 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(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/cli.rs b/etc/trustify-cli/src/cli.rs new file mode 100644 index 000000000..fbcae8ae8 --- /dev/null +++ b/etc/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/etc/trustify-cli/src/commands/auth.rs b/etc/trustify-cli/src/commands/auth.rs new file mode 100644 index 000000000..3f01dbc26 --- /dev/null +++ b/etc/trustify-cli/src/commands/auth.rs @@ -0,0 +1,30 @@ +use clap::Subcommand; +use std::process::ExitCode; + +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) -> 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); + let token = creds.get_token().await?; + println!("{}", token); + Ok(ExitCode::SUCCESS) + } + None => { + eprintln!("Error: SSO URL, client ID, and client secret are all required"); + Err(anyhow::anyhow!("Missing authentication credentials")) + } + }, + } + } +} diff --git a/etc/trustify-cli/src/commands/mod.rs b/etc/trustify-cli/src/commands/mod.rs new file mode 100644 index 000000000..3790d3ad9 --- /dev/null +++ b/etc/trustify-cli/src/commands/mod.rs @@ -0,0 +1,33 @@ +pub mod auth; +pub mod sbom; + +use clap::Subcommand; +use std::process::ExitCode; + +use crate::Context; +pub use auth::AuthCommands; +pub use sbom::SbomCommands; + +#[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) -> 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 new file mode 100644 index 000000000..9bba88550 --- /dev/null +++ b/etc/trustify-cli/src/commands/sbom.rs @@ -0,0 +1,397 @@ +use std::io::{self, Write}; +use std::path::Path; +use std::process::ExitCode; + +use clap::{Subcommand, ValueEnum}; +use serde_json::Value; + +use crate::Context; +use crate::api::sbom as sbom_api; + +/// 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, + + /// 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 { + #[command(subcommand)] + command: DuplicatesCommands, + }, +} + +impl SbomCommands { + pub async fn run(&self, ctx: &Context) -> anyhow::Result { + match self { + 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::List { + query, + limit, + offset, + sort, + format, + } => { + let params = sbom_api::ListParams { + q: query.clone(), + limit: *limit, + offset: *offset, + sort: sort.clone(), + }; + let json = sbom_api::list(&ctx.client, ¶ms).await?; + format_list_output(&json, format)?; + Ok(ExitCode::SUCCESS) + } + SbomCommands::Delete { + id, + query, + dry_run, + concurrency, + limit, + } => { + if let Some(i) = id { + sbom_api::delete(&ctx.client, i).await?; + println!("Deleted SBOM ID: {}", i); + } + if let Some(q) = query { + let delete_result = sbom_api::delete_by_query( + &ctx.client, + Some(q.as_str()), + *dry_run, + *concurrency, + *limit, + ) + .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 + )); + } + 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) -> anyhow::Result { + 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."); + return Ok(ExitCode::SUCCESS); + } + let final_output = final_output.unwrap_or("duplicates.json".to_string()); + + let params = sbom_api::FindDuplicatesParams { + batch_size: *batch_size, + concurrency: *concurrency, + }; + 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, + concurrency, + dry_run, + } => { + let input_path = input + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("duplicates.json"); + + 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)); + } + 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) -> anyhow::Result { + let parsed: Value = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + eprintln!("Error parsing response: {}", e); + return Err(e.into()); + } + }; + + // 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 Ok(ExitCode::SUCCESS); + } + }; + + match format { + ListFormat::Full => { + println!("{}", json); + Ok(ExitCode::SUCCESS) + } + ListFormat::Id => { + for item in items { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + println!("{}", id); + } + } + Ok(ExitCode::SUCCESS) + } + 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(); + let json = serde_json::to_string(&result) + .map_err(|e| anyhow::anyhow!("Failed to serialize output: {}", e))?; + println!("{}", json); + Ok(ExitCode::SUCCESS) + } + 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(); + let json = serde_json::to_string(&result) + .map_err(|e| anyhow::anyhow!("Failed to serialize output: {}", e))?; + println!("{}", json); + Ok(ExitCode::SUCCESS) + } + } +} + +/// 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/etc/trustify-cli/src/config.rs b/etc/trustify-cli/src/config.rs new file mode 100644 index 000000000..b11e299a8 --- /dev/null +++ b/etc/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/etc/trustify-cli/src/main.rs b/etc/trustify-cli/src/main.rs new file mode 100644 index 000000000..d065de4f0 --- /dev/null +++ b/etc/trustify-cli/src/main.rs @@ -0,0 +1,47 @@ +mod api; +mod cli; +mod commands; +mod config; + +use std::process::ExitCode; + +use anyhow::Result; +use clap::Parser; + +use api::ApiClient; +use api::auth::AuthCredentials; +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() -> Result { + // 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); + let token = creds.get_token().await?; + (Some(token), Some(creds)) + } 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 +}