#![warn(clippy::all, clippy::dbg_macro)]
use std::{
collections::HashSet,
env,
io::{Write, stdout},
process::ExitCode,
};
use annotate_snippets::{Group, Level, Renderer};
use anstream::{eprintln, println, stderr, stream::IsTerminal};
use anyhow::anyhow;
use camino::Utf8PathBuf;
use clap::{
ArgAction, Args, CommandFactory, Parser, ValueEnum, ValueHint,
builder::{
NonEmptyStringValueParser,
styling::{AnsiColor, Effects, Styles},
},
};
use clap_complete::Generator;
use clap_verbosity_flag::InfoLevel;
use etcetera::AppStrategy as _;
use finding::{Confidence, Persona, Severity};
use futures::stream::{FuturesOrdered, StreamExt};
use github::{GitHubHost, GitHubToken};
use indicatif::ProgressStyle;
use owo_colors::OwoColorize;
use registry::input::{InputKey, InputRegistry};
use registry::{AuditRegistry, FindingRegistry};
use state::AuditState;
use terminal_link::Link;
use thiserror::Error;
use tracing::{Span, info_span, instrument, warn};
use tracing_indicatif::{IndicatifLayer, span_ext::IndicatifSpanExt};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _};
use crate::{
audit::AuditError,
config::{Config, ConfigError, ConfigErrorInner},
github::Client,
models::AsDocument,
registry::input::CollectionError,
utils::once::warn_once,
};
mod audit;
mod config;
mod finding;
mod github;
#[cfg(feature = "lsp")]
mod lsp;
mod models;
mod output;
mod registry;
mod state;
mod utils;
#[cfg(all(
not(target_family = "windows"),
not(target_os = "openbsd"),
any(
target_arch = "x86_64",
target_arch = "aarch64",
// NOTE(ww): Not a build we currently support.
// target_arch = "powerpc64"
)
))]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
const THANKS: &[(&str, &str)] = &[
("Grafana Labs", "https://grafana.com"),
("Kusari", "https://kusari.dev"),
];
const STYLES: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
.literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.placeholder(AnsiColor::Cyan.on_default());
#[derive(Parser)]
#[command(about, version, styles = STYLES)]
#[command(disable_help_flag = true, disable_version_flag = true)]
#[command(next_display_order = 1)]
struct App {
#[command(flatten)]
input: InputArgs,
#[command(flatten)]
audit: AuditArgs,
#[command(flatten)]
output: OutputArgs,
#[command(flatten)]
network: NetworkArgs,
#[command(flatten)]
args: GlobalArgs,
}
impl App {
fn default_cache_dir() -> Utf8PathBuf {
etcetera::choose_app_strategy(etcetera::AppStrategyArgs {
top_level_domain: "io.github".into(),
author: "woodruffw".into(),
app_name: "zizmor".into(),
})
.expect("failed to determine default cache directory")
.cache_dir()
.try_into()
.expect("failed to turn cache directory into a sane path")
}
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Input Options")]
struct InputArgs {
#[arg(required = true, value_name = "INPUT", display_order = 0)]
inputs: Vec<String>,
#[arg(long, default_values = ["default"], num_args=1.., value_delimiter=',', value_name = "KIND")]
collect: Vec<CliCollectionMode>,
#[arg(long)]
strict_collection: bool,
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Audit Options")]
struct AuditArgs {
#[arg(
long,
value_enum,
value_name = "MODE",
// NOTE: These attributes are needed to make `--fix` behave as the
// default for `--fix=safe`. Unlike other flags we don't support
// `--fix safe`, since `clap` can't disambiguate that.
num_args=0..=1,
require_equals = true,
default_missing_value = "safe",
)]
fix: Option<FixMode>,
#[arg(short, long, group = "_persona")]
pedantic: bool,
#[arg(long, group = "_persona", value_enum, default_value_t)]
persona: Persona,
#[arg(long, value_name = "LEVEL")]
min_severity: Option<CliSeverity>,
#[arg(long, value_name = "LEVEL")]
min_confidence: Option<CliConfidence>,
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Output Options")]
struct OutputArgs {
#[command(flatten)]
verbose: clap_verbosity_flag::Verbosity<InfoLevel>,
#[arg(long, value_enum, default_value_t, value_name = "KIND")]
format: OutputFormat,
#[arg(long)]
no_progress: bool,
#[arg(long, value_enum, value_name = "WHEN")]
color: Option<ColorMode>,
#[arg(
long,
value_enum,
default_value_t,
env = "ZIZMOR_RENDER_LINKS",
value_name = "WHEN"
)]
render_links: CliRenderLinks,
#[arg(
long,
value_enum,
default_value_t,
env = "ZIZMOR_SHOW_AUDIT_URLS",
value_name = "WHEN"
)]
show_audit_urls: CliShowAuditUrls,
#[arg(long)]
no_exit_codes: bool,
#[arg(long, hide = true, env = "ZIZMOR_NACHES")]
naches: bool,
}
#[derive(Args)]
#[command(next_help_heading = "Network Options")]
struct NetworkArgs {
#[arg(short, long, env = "ZIZMOR_OFFLINE")]
offline: bool,
#[arg(long, env, hide_env = true, value_parser = GitHubToken::new)]
gh_token: Option<GitHubToken>,
#[arg(long, env, hide = true, value_parser = GitHubToken::new)]
github_token: Option<GitHubToken>,
#[arg(long, env, hide = true, value_parser = GitHubToken::new)]
zizmor_github_token: Option<GitHubToken>,
#[arg(long, env = "GH_HOST", default_value_t)]
gh_hostname: GitHubHost,
#[arg(long, env = "ZIZMOR_NO_ONLINE_AUDITS")]
no_online_audits: bool,
#[arg(
long,
value_name = "DIR",
default_value_t = App::default_cache_dir(),
hide_default_value = true,
value_hint = ValueHint::DirPath
)]
cache_dir: Utf8PathBuf,
}
#[derive(Args)]
#[command(next_help_heading = "Options")]
struct GlobalArgs {
#[cfg(feature = "lsp")]
#[command(flatten)]
lsp: LspArgs,
#[arg(
short,
long,
value_name = "FILE",
env = "ZIZMOR_CONFIG",
group = "conf",
value_parser = NonEmptyStringValueParser::new(),
value_hint = ValueHint::FilePath
)]
config: Option<String>,
#[arg(long, group = "conf")]
no_config: bool,
#[arg(long, value_enum, value_name = "SHELL", exclusive = true)]
completions: Option<Shell>,
#[cfg(feature = "schema")]
#[arg(long, exclusive = true)]
generate_schema: bool,
#[arg(long, exclusive = true)]
thanks: bool,
#[arg(
short,
long,
help = "Print help (see more with '--help')",
long_help = "Print help (see a summary with '-h')",
action = ArgAction::Help
)]
help: (),
#[arg(short = 'V', long, action = ArgAction::Version)]
version: (),
}
#[derive(Debug, Copy, Clone, ValueEnum)]
enum CliSeverity {
#[value(hide = true)]
Unknown,
Informational,
Low,
Medium,
High,
}
#[derive(Debug, Copy, Clone, ValueEnum)]
enum CliConfidence {
#[value(hide = true)]
Unknown,
Low,
Medium,
High,
}
#[cfg(feature = "lsp")]
#[derive(Args)]
#[group(multiple = true, conflicts_with = "inputs")]
struct LspArgs {
#[arg(long)]
lsp: bool,
#[arg(long, hide = true)]
stdio: bool,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum)]
#[allow(clippy::enum_variant_names)]
enum Shell {
Bash,
Elvish,
Fish,
Nushell,
Powershell,
Zsh,
}
impl Generator for Shell {
fn file_name(&self, name: &str) -> String {
match self {
Shell::Bash => clap_complete::shells::Bash.file_name(name),
Shell::Elvish => clap_complete::shells::Elvish.file_name(name),
Shell::Fish => clap_complete::shells::Fish.file_name(name),
Shell::Nushell => clap_complete_nushell::Nushell.file_name(name),
Shell::Powershell => clap_complete::shells::PowerShell.file_name(name),
Shell::Zsh => clap_complete::shells::Zsh.file_name(name),
}
}
fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::Write) {
match self {
Shell::Bash => clap_complete::shells::Bash.generate(cmd, buf),
Shell::Elvish => clap_complete::shells::Elvish.generate(cmd, buf),
Shell::Fish => clap_complete::shells::Fish.generate(cmd, buf),
Shell::Nushell => clap_complete_nushell::Nushell.generate(cmd, buf),
Shell::Powershell => clap_complete::shells::PowerShell.generate(cmd, buf),
Shell::Zsh => clap_complete::shells::Zsh.generate(cmd, buf),
}
}
}
#[derive(Debug, Default, Copy, Clone, ValueEnum)]
pub(crate) enum OutputFormat {
#[default]
Plain,
Json,
JsonV1,
Sarif,
Github,
}
#[derive(Debug, Default, Copy, Clone, ValueEnum)]
pub(crate) enum CliRenderLinks {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Copy, Clone)]
pub(crate) enum RenderLinks {
Always,
Never,
}
impl From<CliRenderLinks> for RenderLinks {
fn from(value: CliRenderLinks) -> Self {
match value {
CliRenderLinks::Auto => {
if stdout().is_terminal() {
RenderLinks::Always
} else {
RenderLinks::Never
}
}
CliRenderLinks::Always => RenderLinks::Always,
CliRenderLinks::Never => RenderLinks::Never,
}
}
}
#[derive(Debug, Default, Copy, Clone, ValueEnum)]
pub(crate) enum CliShowAuditUrls {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Copy, Clone)]
pub(crate) enum ShowAuditUrls {
Always,
Never,
}
impl From<CliShowAuditUrls> for ShowAuditUrls {
fn from(value: CliShowAuditUrls) -> Self {
match value {
CliShowAuditUrls::Auto => {
if utils::is_ci() || !stdout().is_terminal() {
ShowAuditUrls::Always
} else {
ShowAuditUrls::Never
}
}
CliShowAuditUrls::Always => ShowAuditUrls::Always,
CliShowAuditUrls::Never => ShowAuditUrls::Never,
}
}
}
#[derive(Debug, Copy, Clone, ValueEnum)]
pub(crate) enum ColorMode {
Auto,
Always,
Never,
}
impl ColorMode {
fn color_choice_for_terminal(&self, io: impl IsTerminal) -> anstream::ColorChoice {
match self {
ColorMode::Auto => {
if io.is_terminal() {
anstream::ColorChoice::Always
} else {
anstream::ColorChoice::Never
}
}
ColorMode::Always => anstream::ColorChoice::Always,
ColorMode::Never => anstream::ColorChoice::Never,
}
}
}
impl From<ColorMode> for anstream::ColorChoice {
fn from(value: ColorMode) -> Self {
match value {
ColorMode::Auto => Self::Auto,
ColorMode::Always => Self::Always,
ColorMode::Never => Self::Never,
}
}
}
#[derive(Copy, Clone, Debug, Default, ValueEnum, Eq, PartialEq, Hash)]
pub(crate) enum CliCollectionMode {
All,
#[default]
Default,
#[value(hide = true)]
WorkflowsOnly,
#[value(hide = true)]
ActionsOnly,
Workflows,
Actions,
Dependabot,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub(crate) enum CollectionMode {
All,
Default,
Workflows,
Actions,
Dependabot,
}
pub(crate) struct CollectionModeSet(HashSet<CollectionMode>);
impl From<&[CliCollectionMode]> for CollectionModeSet {
fn from(modes: &[CliCollectionMode]) -> Self {
if modes.len() > 1
&& modes.iter().any(|mode| {
matches!(
mode,
CliCollectionMode::WorkflowsOnly | CliCollectionMode::ActionsOnly
)
})
{
let mut cmd = App::command();
cmd.error(
clap::error::ErrorKind::ArgumentConflict,
"`workflows-only` and `actions-only` cannot be combined with other collection modes",
)
.exit();
}
Self(
modes
.iter()
.map(|mode| match mode {
CliCollectionMode::All => CollectionMode::All,
CliCollectionMode::Default => CollectionMode::Default,
CliCollectionMode::WorkflowsOnly => {
warn!("--collect=workflows-only is deprecated; use --collect=workflows instead");
warn!("future versions of zizmor will reject this mode");
CollectionMode::Workflows
}
CliCollectionMode::ActionsOnly => {
warn!("--collect=actions-only is deprecated; use --collect=actions instead");
warn!("future versions of zizmor will reject this mode");
CollectionMode::Actions
}
CliCollectionMode::Workflows => CollectionMode::Workflows,
CliCollectionMode::Actions => CollectionMode::Actions,
CliCollectionMode::Dependabot => CollectionMode::Dependabot,
})
.collect(),
)
}
}
impl CollectionModeSet {
pub(crate) fn respects_gitignore(&self) -> bool {
!self.0.contains(&CollectionMode::All)
}
pub(crate) fn workflows(&self) -> bool {
self.0.iter().any(|mode| {
matches!(
mode,
CollectionMode::All | CollectionMode::Default | CollectionMode::Workflows
)
})
}
pub(crate) fn workflows_only(&self) -> bool {
self.0.len() == 1 && self.0.contains(&CollectionMode::Workflows)
}
pub(crate) fn actions(&self) -> bool {
self.0.iter().any(|mode| {
matches!(
mode,
CollectionMode::All | CollectionMode::Default | CollectionMode::Actions
)
})
}
pub(crate) fn dependabot(&self) -> bool {
self.0.iter().any(|mode| {
matches!(
mode,
CollectionMode::All | CollectionMode::Default | CollectionMode::Dependabot
)
})
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub(crate) enum FixMode {
Safe,
UnsafeOnly,
All,
}
pub(crate) struct CollectionOptions {
pub(crate) mode_set: CollectionModeSet,
pub(crate) strict: bool,
pub(crate) no_config: bool,
pub(crate) global_config: Option<Config>,
}
#[instrument(skip_all)]
async fn collect_inputs(
inputs: &[String],
options: &CollectionOptions,
gh_client: Option<&Client>,
) -> Result<InputRegistry, CollectionError> {
let mut registry = InputRegistry::new();
for input in inputs.iter() {
registry.register_group(input, options, gh_client).await?;
}
if registry.len() == 0 {
return Err(CollectionError::NoInputs);
}
Ok(registry)
}
fn completions<G: clap_complete::Generator>(generator: G, cmd: &mut clap::Command) {
clap_complete::generate(
generator,
cmd,
cmd.get_name().to_string(),
&mut std::io::stdout(),
);
}
#[derive(Debug, Error)]
enum Error {
#[error(transparent)]
GlobalConfig(#[from] ConfigError),
#[error(transparent)]
Collection(#[from] CollectionError),
#[error(transparent)]
Lsp(#[from] lsp::Error),
#[error(transparent)]
Client(#[from] github::ClientError),
#[error("failed to load audit rules")]
AuditLoad(#[source] anyhow::Error),
#[error("'{ident}' audit failed on {input}")]
Audit {
ident: &'static str,
source: AuditError,
input: String,
},
#[error("failed to render output")]
Output(#[source] anyhow::Error),
#[error("failed to apply fixes")]
Fix(#[source] anyhow::Error),
}
async fn run(app: &mut App) -> Result<ExitCode, Error> {
#[cfg(feature = "lsp")]
if app.args.lsp.lsp {
lsp::run().await?;
return Ok(ExitCode::SUCCESS);
}
if app.args.thanks {
println!("zizmor's development is sustained by our generous sponsors:");
for (name, url) in THANKS {
let link = Link::new(name, url);
println!("🌈 {link}")
}
return Ok(ExitCode::SUCCESS);
}
#[cfg(feature = "schema")]
if app.args.generate_schema {
println!("{}", config::schema::generate_schema());
return Ok(ExitCode::SUCCESS);
}
if let Some(shell) = app.args.completions {
let mut cmd = App::command();
completions(shell, &mut cmd);
return Ok(ExitCode::SUCCESS);
}
let color_mode = match app.output.color {
Some(color_mode) => color_mode,
None => {
if std::env::var("NO_COLOR").is_ok() {
ColorMode::Never
} else if std::env::var("FORCE_COLOR").is_ok()
|| std::env::var("CLICOLOR_FORCE").is_ok()
|| utils::is_ci()
{
ColorMode::Always
} else {
ColorMode::Auto
}
}
};
anstream::ColorChoice::write_global(color_mode.into());
if matches!(color_mode, ColorMode::Never) || !stderr().is_terminal() {
app.output.no_progress = true;
}
if app.audit.pedantic {
app.audit.persona = Persona::Pedantic;
}
app.network.gh_token = app
.network
.gh_token
.take()
.or(app.network.github_token.take())
.or(app.network.zizmor_github_token.take());
if app.network.offline {
app.network.gh_token = None;
}
let indicatif_layer = IndicatifLayer::new();
let writer = std::sync::Mutex::new(anstream::AutoStream::new(
Box::new(indicatif_layer.get_stderr_writer()) as Box<dyn Write + Send>,
color_mode.color_choice_for_terminal(std::io::stderr()),
));
let filter = EnvFilter::builder()
.with_default_directive(app.output.verbose.tracing_level_filter().into())
.from_env()
.expect("failed to parse RUST_LOG");
#[allow(clippy::unwrap_used)]
let filter = filter.add_directive("http_cache::managers::cacache=error".parse().unwrap());
let reg = tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.without_time()
.with_writer(writer),
)
.with(filter);
if app.output.no_progress {
reg.init();
} else {
reg.with(indicatif_layer).init();
}
tracing::info!("🌈 zizmor v{version}", version = env!("CARGO_PKG_VERSION"));
if app.input.inputs.iter().any(|i| i == "-") {
if app.input.inputs.len() > 1 {
let mut cmd = App::command();
cmd.error(
clap::error::ErrorKind::ArgumentConflict,
"`-` (stdin) cannot be combined with other inputs",
)
.exit();
}
if app.audit.fix.is_some() {
let mut cmd = App::command();
cmd.error(
clap::error::ErrorKind::ArgumentConflict,
"`--fix` cannot be used with `-` (stdin)",
)
.exit();
}
}
let collection_mode_set = CollectionModeSet::from(app.input.collect.as_slice());
let min_severity = match app.audit.min_severity {
Some(CliSeverity::Unknown) => {
tracing::warn!("`unknown` is a deprecated minimum severity that has no effect");
tracing::warn!("future versions of zizmor will reject this value");
None
}
Some(CliSeverity::Informational) => Some(Severity::Informational),
Some(CliSeverity::Low) => Some(Severity::Low),
Some(CliSeverity::Medium) => Some(Severity::Medium),
Some(CliSeverity::High) => Some(Severity::High),
None => None,
};
let min_confidence = match app.audit.min_confidence {
Some(CliConfidence::Unknown) => {
tracing::warn!("`unknown` is a deprecated minimum confidence that has no effect");
tracing::warn!("future versions of zizmor will reject this value");
None
}
Some(CliConfidence::Low) => Some(Confidence::Low),
Some(CliConfidence::Medium) => Some(Confidence::Medium),
Some(CliConfidence::High) => Some(Confidence::High),
None => None,
};
let global_config = Config::global(app)?;
let gh_client = app
.network
.gh_token
.as_ref()
.map(|token| Client::new(&app.network.gh_hostname, token, &app.network.cache_dir))
.transpose()?;
let collection_options = CollectionOptions {
mode_set: collection_mode_set,
strict: app.input.strict_collection,
no_config: app.args.no_config,
global_config,
};
let registry = collect_inputs(
app.input.inputs.as_slice(),
&collection_options,
gh_client.as_ref(),
)
.await?;
let state = AuditState::new(app.network.no_online_audits, gh_client);
let audit_registry = AuditRegistry::default_audits(&state).map_err(Error::AuditLoad)?;
let mut results =
FindingRegistry::new(®istry, min_severity, min_confidence, app.audit.persona);
{
let span = info_span!("audit");
span.pb_set_length((registry.len() * audit_registry.len()) as u64);
span.pb_set_style(
&ProgressStyle::with_template("[{elapsed_precise}] {bar:!30.cyan/blue} {msg}")
.expect("couldn't set progress bar style"),
);
let _guard = span.enter();
for (input_key, input) in registry.iter_inputs() {
Span::current().pb_set_message(input.key().filename());
if input.as_document().has_anchors() {
warn_once!(
"one or more inputs contains YAML anchors; see https://docs.zizmor.sh/usage/#yaml-anchors for details"
);
}
let mut completion_stream = FuturesOrdered::new();
let config = registry.get_config(input_key.group());
for (ident, audit) in audit_registry.iter_audits() {
tracing::debug!("scheduling {ident} on {input}", input = input.key());
completion_stream.push_back(audit.audit(ident, input, config));
}
while let Some(findings) = completion_stream.next().await {
let findings = findings.map_err(|err| Error::Audit {
ident: err.ident(),
source: err,
input: input.key().to_string(),
})?;
results.extend(findings);
Span::current().pb_inc(1);
}
tracing::info!(
"🌈 completed {input}",
input = input.key().presentation_path()
);
}
}
match app.output.format {
OutputFormat::Plain => output::plain::render_findings(
®istry,
&results,
&app.output.show_audit_urls.into(),
&app.output.render_links.into(),
app.output.naches,
),
OutputFormat::Json | OutputFormat::JsonV1 => {
output::json::v1::output(stdout(), results.findings()).map_err(Error::Output)?
}
OutputFormat::Sarif => {
serde_json::to_writer_pretty(stdout(), &output::sarif::build(results.findings()))
.map_err(|err| Error::Output(anyhow!(err)))?
}
OutputFormat::Github => {
output::github::output(stdout(), results.findings()).map_err(Error::Output)?
}
};
let all_fixed = if let Some(fix_mode) = app.audit.fix {
let fix_result =
output::fix::apply_fixes(fix_mode, &results, ®istry).map_err(Error::Fix)?;
results.all_findings_have_applicable_fixes(fix_mode)
&& fix_result.failed_count == 0
&& fix_result.applied_count > 0
} else {
false
};
if app.output.no_exit_codes || matches!(app.output.format, OutputFormat::Sarif) {
Ok(ExitCode::SUCCESS)
} else if all_fixed {
Ok(ExitCode::SUCCESS)
} else {
Ok(results.exit_code())
}
}
#[tokio::main]
async fn main() -> ExitCode {
human_panic::setup_panic!();
let mut app = App::parse();
match run(&mut app).await {
Ok(exit) => exit,
Err(err) => {
eprintln!(
"{fatal}: no audit was performed",
fatal = "fatal".red().bold()
);
let report = match &err {
Error::GlobalConfig(err) | Error::Collection(CollectionError::Config(err)) => {
let mut group = Group::with_title(Level::ERROR.primary_title(err.to_string()));
match err.source {
ConfigErrorInner::Syntax(_) => {
group = group.elements([
Level::HELP
.message("check your configuration file for syntax errors"),
Level::HELP.message("see: https://docs.zizmor.sh/configuration/"),
]);
}
ConfigErrorInner::AuditSyntax(_, ident) => {
group = group.elements([
Level::HELP.message(format!(
"check the configuration for the '{ident}' rule"
)),
Level::HELP.message(format!(
"see: https://docs.zizmor.sh/audits/#{ident}-configuration"
)),
]);
}
_ => {}
}
let renderer = Renderer::styled();
let report = renderer.render(&[group]);
Some(report)
}
Error::Collection(err) => match err.inner() {
CollectionError::NoInputs => {
let group = Group::with_title(Level::ERROR.primary_title(err.to_string()))
.element(Level::HELP.message("collection yielded no auditable inputs"))
.element(Level::HELP.message("inputs must contain at least one valid workflow, action, or Dependabot config"));
let renderer = Renderer::styled();
let report = renderer.render(&[group]);
Some(report)
}
CollectionError::DuplicateInput(..) => {
let group = Group::with_title(Level::ERROR.primary_title(err.to_string()))
.element(Level::HELP.message(format!(
"valid inputs are files, directories, GitHub {slug} slugs, or {stdin} for stdin",
slug = "user/repo[@ref]".green(),
stdin = "-".green()
)))
.element(Level::HELP.message(format!(
"examples: {ex1}, {ex2}, {ex3}, {ex4}, or {ex5}",
ex1 = "path/to/workflow.yml".green(),
ex2 = ".github/".green(),
ex3 = "example/example".green(),
ex4 = "example/example@v1.2.3".green(),
ex5 = "-".green()
)));
let renderer = Renderer::styled();
let report = renderer.render(&[group]);
Some(report)
}
CollectionError::NoGitHubClient(..) => {
let mut group =
Group::with_title(Level::ERROR.primary_title(err.to_string()));
if app.network.offline {
group = group.elements([Level::HELP
.message("remove --offline to audit remote repositories")]);
} else if app.network.gh_token.is_none() {
group = group.elements([Level::HELP
.message("set a GitHub token with --gh-token or GH_TOKEN")]);
}
let renderer = Renderer::styled();
let report = renderer.render(&[group]);
Some(report)
}
CollectionError::Yamlpath(..) | CollectionError::Model(..) => {
let group = Group::with_title(Level::ERROR.primary_title(err.to_string())).elements([
Level::HELP.message("this typically indicates a bug in zizmor; please report it"),
Level::HELP.message(
"https://github.com/zizmorcore/zizmor/issues/new?template=bug-report.yml",
),
]);
let renderer = Renderer::styled();
let report = renderer.render(&[group]);
Some(report)
}
CollectionError::RemoteWithoutWorkflows(_, slug) => {
let group = Group::with_title(Level::ERROR.primary_title(err.to_string()))
.elements([
Level::HELP.message(
format!(
"ensure that {slug} contains one or more workflows under `.github/workflows/`"
)
),
Level::HELP.message(
format!("ensure that {slug} exists and you have access to it")
)
]);
let renderer = Renderer::styled();
let report = renderer.render(&[group]);
Some(report)
}
_ => None,
},
_ => None,
};
let exit = if matches!(err, Error::Collection(CollectionError::NoInputs)) {
ExitCode::from(3)
} else {
ExitCode::FAILURE
};
let mut err = anyhow!(err);
if let Some(report) = report {
err = err.context(report);
}
eprintln!("{err:?}");
exit
}
}
}