use std::collections::HashSet;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::time::SystemTime;
use clap::{Parser, Subcommand};
use gem_audit::advisory::{Criticality, Database};
use gem_audit::configuration::Configuration;
use gem_audit::fixer::{self, FixResult};
use gem_audit::format::{self, OutputFormat};
use gem_audit::scanner::{ScanOptions, Scanner};
use gem_audit::util::format_timestamp;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const EXIT_SUCCESS: i32 = 0;
const EXIT_VULNERABLE: i32 = 1;
const EXIT_ERROR: i32 = 2;
const EXIT_STALE: i32 = 3;
#[derive(Parser)]
#[command(
name = "gem-audit",
about = "Patch-level verification for Ruby Bundler dependencies",
version = VERSION,
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Check {
#[arg(default_value = ".")]
dir: String,
#[arg(short, long)]
quiet: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short, long, num_args = 1..)]
ignore: Vec<String>,
#[arg(short, long)]
update: bool,
#[arg(short = 'D', long)]
database: Option<String>,
#[arg(short = 'F', long, value_enum, default_value = "text")]
format: OutputFormat,
#[arg(short = 'G', long, default_value = "Gemfile.lock")]
gemfile_lock: String,
#[arg(short, long, default_value = ".gem-audit.yml")]
config: String,
#[arg(short, long)]
output: Option<String>,
#[arg(short = 'S', long, value_enum)]
severity: Option<Criticality>,
#[arg(long)]
max_db_age: Option<u64>,
#[arg(long)]
fail_on_stale: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
fix: bool,
#[arg(long)]
dry_run: bool,
},
Update {
#[arg(short, long)]
quiet: bool,
#[arg(short = 'D', long)]
database: Option<String>,
},
Download {
#[arg(short, long)]
quiet: bool,
#[arg(short = 'D', long)]
database: Option<String>,
},
Stats {
#[arg(short = 'D', long)]
database: Option<String>,
},
Version,
}
fn main() {
let cli = Cli::parse();
let code = match cli.command {
Some(Commands::Check {
dir,
quiet,
verbose,
ignore,
update,
database,
format,
gemfile_lock,
config,
output,
severity,
max_db_age,
fail_on_stale,
strict,
fix,
dry_run,
}) => cmd_check(
&dir,
quiet,
verbose,
&ignore,
update,
database.as_deref(),
format,
&gemfile_lock,
&config,
output.as_deref(),
severity,
max_db_age,
fail_on_stale,
strict,
fix,
dry_run,
),
Some(Commands::Update { quiet, database }) => cmd_update(quiet, database.as_deref()),
Some(Commands::Download { quiet, database }) => cmd_download(quiet, database.as_deref()),
Some(Commands::Stats { database }) => cmd_stats(database.as_deref()),
Some(Commands::Version) => {
println!("gem-audit {}", VERSION);
EXIT_SUCCESS
}
None => {
cmd_check(
".",
false,
false,
&[],
false,
None,
OutputFormat::Text,
"Gemfile.lock",
Configuration::DEFAULT_FILE,
None,
None,
None,
false,
false,
false,
false,
)
}
};
if code != EXIT_SUCCESS {
process::exit(code);
}
}
fn resolve_db_path(database: Option<&str>) -> PathBuf {
database
.map(PathBuf::from)
.unwrap_or_else(Database::default_path)
}
#[allow(clippy::too_many_arguments)]
fn cmd_check(
dir: &str,
quiet: bool,
verbose: bool,
ignore: &[String],
update: bool,
database: Option<&str>,
output_format: OutputFormat,
gemfile_lock: &str,
config_file: &str,
output_file: Option<&str>,
severity: Option<Criticality>,
max_db_age: Option<u64>,
fail_on_stale: bool,
strict: bool,
fix: bool,
dry_run: bool,
) -> i32 {
let dir = Path::new(dir);
if !dir.is_dir() {
eprintln!("No such file or directory: {}", dir.display());
return EXIT_ERROR;
}
let config_path = if Path::new(config_file).is_absolute() {
PathBuf::from(config_file)
} else {
dir.join(config_file)
};
let config = match Configuration::load_or_default(&config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}", e);
return EXIT_ERROR;
}
};
let db_path = resolve_db_path(database);
if !db_path.is_dir() || !db_path.join("gems").is_dir() {
if !quiet {
eprintln!("Downloading ruby-advisory-db ...");
}
match Database::download(&db_path, quiet) {
Ok(_) => {
if !quiet {
eprintln!("Downloaded ruby-advisory-db");
}
}
Err(e) => {
eprintln!("Failed to download advisory database: {}", e);
return EXIT_ERROR;
}
}
} else if update {
if !quiet {
eprintln!("Updating ruby-advisory-db ...");
}
let db = Database::open(&db_path).unwrap();
match db.update() {
Ok(true) => {
if !quiet {
eprintln!("Updated ruby-advisory-db");
}
}
Ok(false) => {
if !quiet {
eprintln!("Skipping update, ruby-advisory-db is not a git repository");
}
}
Err(e) => {
eprintln!("warning: Failed to update advisory database: {}", e);
}
}
}
let db = match Database::open(&db_path) {
Ok(db) => db,
Err(e) => {
eprintln!("Failed to open advisory database: {}", e);
return EXIT_ERROR;
}
};
let effective_max_age = max_db_age.or(config.max_db_age_days);
let mut stale = false;
if let Some(max_days) = effective_max_age
&& let Some(last_updated) = db.last_updated_at()
{
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let age_days = (now - last_updated) / 86400;
if age_days > max_days as i64 {
stale = true;
eprintln!(
"warning: advisory database is {} days old (max: {} days)",
age_days, max_days
);
}
}
let lockfile_path = dir.join(gemfile_lock);
let scanner = match Scanner::new(&lockfile_path, db) {
Ok(s) => s,
Err(e) => {
eprintln!("{}", e);
return EXIT_ERROR;
}
};
let ignore_set = if !ignore.is_empty() {
ignore.iter().cloned().collect::<HashSet<String>>()
} else {
config.ignore
};
let options = ScanOptions {
ignore: ignore_set,
severity,
strict,
};
let report = scanner.scan(&options);
let stdout = io::stdout();
let is_tty = stdout.is_terminal();
let mut output_handle: Box<dyn Write> = if let Some(path) = output_file {
match std::fs::File::create(path) {
Ok(f) => Box::new(f),
Err(e) => {
eprintln!("Failed to open output file {}: {}", path, e);
return EXIT_ERROR;
}
}
} else {
Box::new(stdout.lock())
};
let fix_results = if fix && !report.unpatched_gems.is_empty() {
let remediations = report.remediations();
Some(fixer::resolve_fixes(&remediations))
} else {
None
};
match output_format {
OutputFormat::Text => {
let use_color = output_file.is_none() && is_tty;
format::print_text(
&report,
&mut output_handle,
verbose,
quiet,
use_color,
fix,
fix_results.as_deref(),
);
}
OutputFormat::Json => {
format::print_json(
&report,
&mut output_handle,
is_tty && output_file.is_none(),
fix,
fix_results.as_deref(),
);
}
}
if fix
&& !dry_run
&& let Some(ref results) = fix_results
{
let fixes: Vec<&fixer::FixSuggestion> = results
.iter()
.filter_map(|r| match r {
FixResult::Fixed(f) => Some(f),
_ => None,
})
.collect();
if !fixes.is_empty() {
let owned_fixes: Vec<fixer::FixSuggestion> = fixes
.iter()
.map(|f| fixer::FixSuggestion {
name: f.name.clone(),
current_version: f.current_version.clone(),
resolved_version: f.resolved_version.clone(),
advisory_ids: f.advisory_ids.clone(),
})
.collect();
match std::fs::read_to_string(&lockfile_path) {
Ok(content) => {
let (patched, patched_names) = fixer::patch_lockfile(&content, &owned_fixes);
if !patched_names.is_empty() {
let tmp_path = lockfile_path.with_extension("lock.tmp");
match std::fs::write(&tmp_path, &patched) {
Ok(()) => {
if let Err(e) = std::fs::rename(&tmp_path, &lockfile_path) {
eprintln!("error: failed to write Gemfile.lock: {}", e);
let _ = std::fs::remove_file(&tmp_path);
} else {
eprintln!(
"\nFixed {} gem(s) in {}. Run `bundle install` to install the updated versions.",
patched_names.len(),
lockfile_path.display()
);
}
}
Err(e) => {
eprintln!("error: failed to write temporary file: {}", e);
}
}
}
}
Err(e) => {
eprintln!("error: failed to read {}: {}", lockfile_path.display(), e);
}
}
}
}
if report.vulnerable() {
return EXIT_VULNERABLE;
}
if strict && (report.version_parse_errors > 0 || report.advisory_load_errors > 0) {
return EXIT_ERROR;
}
if stale && fail_on_stale {
return EXIT_STALE;
}
EXIT_SUCCESS
}
fn cmd_update(quiet: bool, database: Option<&str>) -> i32 {
let db_path = resolve_db_path(database);
if !db_path.is_dir() || !db_path.join("gems").is_dir() {
return cmd_download(quiet, database);
}
if !quiet {
eprintln!("Updating ruby-advisory-db ...");
}
let db = match Database::open(&db_path) {
Ok(db) => db,
Err(e) => {
eprintln!("Failed to open advisory database: {}", e);
return EXIT_ERROR;
}
};
match db.update() {
Ok(true) => {
if !quiet {
eprintln!("Updated ruby-advisory-db");
}
}
Ok(false) => {
if !quiet {
eprintln!("Skipping update, ruby-advisory-db is not a git repository");
}
}
Err(e) => {
eprintln!("Failed to update: {}", e);
return EXIT_ERROR;
}
}
if !quiet {
print_stats(&db);
}
EXIT_SUCCESS
}
fn cmd_download(quiet: bool, database: Option<&str>) -> i32 {
let db_path = resolve_db_path(database);
if db_path.is_dir() && db_path.join("gems").is_dir() {
eprintln!("Database already exists");
return EXIT_SUCCESS;
}
if !quiet {
eprintln!("Downloading ruby-advisory-db ...");
}
match Database::download(&db_path, quiet) {
Ok(db) => {
if !quiet {
print_stats(&db);
}
EXIT_SUCCESS
}
Err(e) => {
eprintln!("Failed to download: {}", e);
EXIT_ERROR
}
}
}
fn cmd_stats(database: Option<&str>) -> i32 {
let db_path = resolve_db_path(database);
let db = match Database::open(&db_path) {
Ok(db) => db,
Err(e) => {
eprintln!("Failed to open advisory database: {}", e);
return EXIT_ERROR;
}
};
print_stats(&db);
EXIT_SUCCESS
}
fn print_stats(db: &Database) {
let gems = db.size();
let rubies = db.rubies_size();
println!("ruby-advisory-db:");
println!(" advisories:\t{} advisories", gems + rubies);
if rubies > 0 {
println!(" gems:\t\t{}", gems);
println!(" rubies:\t{}", rubies);
}
if let Some(ts) = db.last_updated_at() {
println!(" last updated:\t{}", format_timestamp(ts));
}
if let Some(commit) = db.commit_id() {
println!(" commit:\t{}", commit);
}
}