use clap::Parser;
use deps_cli::MAX_MANIFEST_FILE_SIZE;
use deps_cli::analyze::analyze_manifest;
use deps_cli::cli::{Cli, Command, OutputFormat, UpdateArgs, UpdateOutputFormat};
use deps_cli::config::{self, CliConfig};
use deps_cli::exit::{ExecutionOutcome, exit_code};
use deps_cli::report::{CheckContext, CheckReport, FailOnPolicy, check_manifest};
use deps_cli::update::ignore::IgnoreRules;
use deps_cli::update::{self, UpdatePlan};
use deps_cli::{format, walk};
use deps_core::osv::OsvClient;
use deps_core::policy_config::PolicyConfig;
use deps_core::{EcosystemRegistry, HttpCache, NetworkMode};
use deps_engine::setup::{EcosystemRuntime, register_ecosystems};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
fn main() -> ExitCode {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.fmt_fields(sanitizing_field_format())
.init();
let cli = Cli::parse();
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
eprintln!("deps-cli: failed to start async runtime: {error}");
return ExitCode::from(2);
}
};
match cli.command {
Command::Check(args) => run_check_command(&runtime, &args),
Command::Update(args) => run_update_command(&runtime, &args),
}
}
fn sanitizing_field_format()
-> impl for<'writer> tracing_subscriber::fmt::FormatFields<'writer> + 'static {
use tracing_subscriber::field::MakeExt;
tracing_subscriber::fmt::format::debug_fn(|writer, field, value| {
if field.name().starts_with("log.") {
return Ok(());
}
let rendered = format!("{value:?}");
let sanitized = deps_core::redact::sanitize_invisible(&rendered);
if field.name() == "message" {
write!(writer, "{sanitized}")
} else {
write!(writer, "{}={sanitized}", field.name())
}
})
.delimited(" ")
}
struct RuntimeHandles {
osv: Arc<OsvClient>,
deps_dev: Arc<deps_core::DepsDevClient>,
lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
cache: Arc<HttpCache>,
ecosystem_registry: EcosystemRegistry,
}
fn build_runtime_handles(policy: &PolicyConfig) -> RuntimeHandles {
let lockfile_cache = Arc::new(deps_core::lockfile::LockFileCache::new());
let ecosystem_runtime =
EcosystemRuntime::from_policy(policy).with_lockfile_cache(Arc::clone(&lockfile_cache));
let cache = Arc::new(HttpCache::with_policy(Arc::clone(
&ecosystem_runtime.policy,
)));
cache.set_offline(NetworkMode::from_offline_flag(policy.network.offline));
let ecosystem_registry = EcosystemRegistry::new();
let _workspace_registry_ecosystems =
register_ecosystems(&ecosystem_registry, Arc::clone(&cache), &ecosystem_runtime);
RuntimeHandles {
osv: Arc::new(OsvClient::new(Arc::clone(&cache))),
deps_dev: Arc::new(deps_core::DepsDevClient::new(Arc::clone(&cache))),
lockfile_cache,
cache,
ecosystem_registry,
}
}
fn run_check_command(
runtime: &tokio::runtime::Runtime,
args: &deps_cli::cli::CheckArgs,
) -> ExitCode {
let walk_paths = args.walk_paths();
let default_config_dir = config_default_dir(&walk_paths);
let cli_config = match config::load(args.config.as_deref(), &default_config_dir) {
Ok(config) => config::apply_overrides(
config,
NetworkMode::from_offline_flag(args.offline),
args.cooldown,
),
Err(error) => {
eprintln!("deps-cli: {error}");
return ExitCode::from(2);
}
};
let fail_on = if args.fail_on.is_empty() {
FailOnPolicy::default_categories()
} else {
FailOnPolicy::new(args.fail_on.clone())
};
let (report, had_execution_error) = runtime.block_on(run_check(
walk_paths,
cli_config,
args.gitignore_policy(),
args.symlink_policy(),
));
let rendered = match args.format {
OutputFormat::Table => format::table::render(&report),
OutputFormat::Json => match format::json::render(&report) {
Ok(json) => json,
Err(error) => {
eprintln!("deps-cli: failed to render JSON report: {error}");
return ExitCode::from(2);
}
},
OutputFormat::Sarif => match format::sarif::render(&report) {
Ok(sarif) => sarif,
Err(error) => {
eprintln!("deps-cli: failed to render SARIF report: {error}");
return ExitCode::from(2);
}
},
};
print!("{rendered}");
ExitCode::from(
u8::try_from(exit_code(
&report,
&fail_on,
ExecutionOutcome::from_had_execution_error(had_execution_error),
))
.unwrap_or(2),
)
}
async fn run_check(
paths: Vec<PathBuf>,
cli_config: CliConfig,
gitignore_policy: walk::GitignorePolicy,
symlink_policy: walk::SymlinkPolicy,
) -> (CheckReport, bool) {
let policy = cli_config.policy;
let handles = build_runtime_handles(&policy);
let ctx = CheckContext {
cache: Arc::clone(&handles.cache),
osv: handles.osv,
deps_dev: handles.deps_dev,
lockfile_cache: handles.lockfile_cache,
policy,
};
let walk_outcome = walk::walk(
&paths,
&handles.ecosystem_registry,
gitignore_policy,
symlink_policy,
);
let mut had_execution_error = false;
for error in walk_outcome.walk_errors() {
eprintln!("deps-cli: warning: {error}");
had_execution_error = true;
}
if walk_outcome.truncated {
eprintln!(
"deps-cli: warning: walk truncated at {} entries; some manifests may be missing from this report",
walk::MAX_WALKED_FILES
);
had_execution_error = true;
}
for path in walk_outcome.unrecognized_explicit_paths() {
eprintln!(
"deps-cli: warning: {} is not recognized by any ecosystem",
path.display()
);
}
for path in walk_outcome.ignored_manifests() {
eprintln!(
"deps-cli: warning: {} looks like a manifest but was excluded from the scan (a .gitignore/.ignore rule, a pruned directory such as vendor/build/dist, or a symlink not followed — see --follow-symlinks)",
path.display()
);
had_execution_error = true;
}
for path in walk_outcome.broken_manifest_symlinks() {
eprintln!(
"deps-cli: warning: {} is a manifest-shaped symlink whose target could not be resolved (does not exist, a broken chain, is unreadable, or is not a regular file) — this is stronger evidence of tampering than an ordinary excluded manifest",
path.display()
);
had_execution_error = true;
}
if walk_outcome.manifests().is_empty() {
eprintln!("deps-cli: warning: no manifests were discovered under the given path(s)");
had_execution_error = true;
}
let mut findings = Vec::new();
for manifest in walk_outcome.manifests() {
match deps_core::fs_probe::read_to_string_capped(&manifest.path, MAX_MANIFEST_FILE_SIZE) {
Ok(Some(content)) => {
match check_manifest(
&manifest.ecosystem,
&manifest.uri_path,
&manifest.display_path,
&content,
&ctx,
)
.await
{
Ok(result) => {
had_execution_error |=
result.registry_unreachable || result.license_fetch_incomplete;
findings.extend(result.findings);
}
Err(error) => {
eprintln!("deps-cli: warning: {error}");
had_execution_error = true;
}
}
}
Ok(None) => {
eprintln!(
"deps-cli: warning: {} exceeds the manifest size cap, skipping",
manifest.display_path.display()
);
had_execution_error = true;
}
Err(error) => {
eprintln!(
"deps-cli: warning: could not read {}: {error}",
manifest.display_path.display()
);
had_execution_error = true;
}
}
}
(CheckReport { findings }, had_execution_error)
}
fn config_default_dir(paths: &[PathBuf]) -> PathBuf {
match paths {
[only] if only.is_dir() => only.clone(),
[only] => only
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf),
_ => PathBuf::from("."),
}
}
fn load_update_config(explicit_path: Option<&Path>) -> Result<CliConfig, config::ConfigError> {
match explicit_path {
Some(path) => config::load(Some(path), Path::new(".")),
None => Ok(CliConfig::default()),
}
}
fn run_update_command(runtime: &tokio::runtime::Runtime, args: &UpdateArgs) -> ExitCode {
let cli_config = match load_update_config(args.config.as_deref()) {
Ok(config) => config::apply_overrides(
config,
NetworkMode::from_offline_flag(args.offline),
args.cooldown,
),
Err(error) => {
eprintln!("deps-cli: {error}");
return ExitCode::from(2);
}
};
let policy = cli_config.policy;
let freshness_non_default =
policy.freshness.cooldown_secs != PolicyConfig::default().freshness.cooldown_secs;
if args.security_only {
let gossip_enabled = policy.gossip.enabled;
let source = match (freshness_non_default, gossip_enabled) {
(true, true) => Some("a non-default freshness cooldown and GOSSIP"),
(true, false) => Some("a non-default freshness cooldown"),
(false, true) => Some("GOSSIP"),
(false, false) => None,
};
if let Some(source) = source {
eprintln!(
"deps-cli: warning: {source} has no effect under --security-only (the fix target comes from the advisory, not the freshness/GOSSIP-filtered registry pick)"
);
}
} else if freshness_non_default && !policy.freshness.enabled {
eprintln!(
"deps-cli: warning: a non-default freshness cooldown has no effect because [freshness].enabled is false"
);
}
if args.security_only && (policy.network.offline || !policy.diagnostics.vulnerabilities_enabled)
{
eprintln!(
"deps-cli: error: --security-only requires network access and vulnerability scanning to be enabled (network.offline and diagnostics.vulnerabilities_enabled)"
);
return ExitCode::from(2);
}
let ignore_config = args
.config
.is_some()
.then(|| cli_config.update.ignore.clone());
match runtime.block_on(run_update(args, policy, ignore_config)) {
Ok(plan) => {
let rendered = match args.format {
UpdateOutputFormat::Table => {
format::table::render_update(&plan, format::DryRun::from_flag(args.dry_run))
}
UpdateOutputFormat::Json => {
match format::json::render_update(
&plan,
format::DryRun::from_flag(args.dry_run),
) {
Ok(json) => json,
Err(error) => {
eprintln!("deps-cli: failed to render JSON report: {error}");
return ExitCode::from(2);
}
}
}
};
print!("{rendered}");
ExitCode::from(u8::try_from(deps_cli::exit::update_exit_code(&plan)).unwrap_or(2))
}
Err(message) => {
eprintln!("deps-cli: error: {message}");
ExitCode::from(2)
}
}
}
async fn run_update(
args: &UpdateArgs,
policy: PolicyConfig,
ignore_config: Option<Vec<deps_cli::config::IgnoreRule>>,
) -> Result<UpdatePlan, String> {
let handles = build_runtime_handles(&policy);
let walk_outcome = walk::walk(
std::slice::from_ref(&args.manifest),
&handles.ecosystem_registry,
walk::GitignorePolicy::Ignore,
walk::SymlinkPolicy::Skip,
);
if walk_outcome.manifests().len() != 1
|| !walk_outcome.unrecognized_explicit_paths().is_empty()
|| !walk_outcome.broken_manifest_symlinks().is_empty()
|| !walk_outcome.ignored_manifests().is_empty()
|| !walk_outcome.walk_errors().is_empty()
{
return Err(format!(
"{} is not a single recognized manifest",
args.manifest.display()
));
}
let Some(manifest) = walk_outcome.manifests().first() else {
return Err(format!(
"{} is not a single recognized manifest",
args.manifest.display()
));
};
let content = match deps_core::fs_probe::read_to_string_capped_no_follow(
&manifest.path,
MAX_MANIFEST_FILE_SIZE,
) {
Ok(Some(content)) => content,
Ok(None) => {
return Err(format!(
"{} exceeds the manifest size cap",
manifest.display_path.display()
));
}
Err(error) => {
return Err(format!(
"could not read {}: {error}",
manifest.display_path.display()
));
}
};
let ctx = CheckContext {
cache: Arc::clone(&handles.cache),
osv: Arc::clone(&handles.osv),
deps_dev: Arc::clone(&handles.deps_dev),
lockfile_cache: handles.lockfile_cache,
policy,
};
let scope = if args.security_only {
deps_cli::analyze::AnalysisScope::vulnerabilities_only()
} else {
deps_cli::analyze::AnalysisScope::update_default()
};
let analysis = analyze_manifest(
&manifest.ecosystem,
&manifest.uri_path,
&content,
&ctx,
scope,
)
.await
.map_err(|error| error.to_string())?;
if analysis.registry_unreachable {
return Err(format!(
"a registry required to classify {} was unreachable; the plan would be based on \
incomplete data",
manifest.display_path.display()
));
}
let formatter = manifest.ecosystem.formatter();
let ignore_rules = match ignore_config {
Some(rules) => IgnoreRules::new(rules, formatter),
None => IgnoreRules::empty(),
};
if !args.security_only
&& analysis.has_unverified_latest_check(formatter, &args.package, &ignore_rules)
{
return Err(format!(
"the OSV.dev latest-version check for one or more dependencies in {} could not be \
completed (timeout or query failure); the plan would be based on unverified data",
manifest.display_path.display()
));
}
let mut plan = if args.security_only {
update::security::plan_security_updates(
&analysis,
manifest.ecosystem.as_ref(),
&ctx.osv,
&args.package,
&ignore_rules,
ctx.policy.cache.fetch_timeout_secs,
)
.await
} else {
let reparse = deps_core::edit::EcosystemReparse {
ecosystem: manifest.ecosystem.as_ref(),
uri: &analysis.uri,
};
update::plan_updates(
&analysis,
&content,
formatter,
&reparse,
&args.package,
&ignore_rules,
ctx.policy.freshness.to_freshness(),
analysis.now,
)
};
update::dedup_applied_items(&mut plan.items);
if !args.security_only
&& analysis.latest_status.is_none()
&& plan
.items
.iter()
.any(|item| matches!(item.outcome, update::Outcome::Applied { .. }))
{
let reason = if ctx.policy.network.offline {
"network.offline is set"
} else {
"diagnostics.vulnerabilities_enabled is disabled"
};
eprintln!(
"deps-cli: warning: the latest version(s) applied to {} were not checked against \
OSV.dev ({reason}); this update proceeded on unverified data",
manifest.display_path.display()
);
}
update::apply_plan(
&plan,
&manifest.path,
&content,
format::DryRun::from_flag(args.dry_run),
)
.map_err(|error| error.to_string())?;
Ok(plan)
}
#[cfg(test)]
mod sanitizing_field_format_tests {
use super::sanitizing_field_format;
use std::io;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct BufWriter(Arc<Mutex<Vec<u8>>>);
impl io::Write for BufWriter {
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
fn capture(f: impl FnOnce()) -> String {
let buf = Arc::new(Mutex::new(Vec::<u8>::new()));
let subscriber = tracing_subscriber::fmt()
.with_writer(BufWriter(Arc::clone(&buf)))
.with_ansi(false)
.without_time()
.with_target(false)
.with_level(false)
.fmt_fields(sanitizing_field_format())
.finish();
let guard = tracing::subscriber::set_default(subscriber);
f();
drop(guard);
let captured = buf.lock().unwrap().clone();
String::from_utf8(captured).expect("log output is valid utf8")
}
#[test]
fn sanitizes_percent_field_and_message_control_chars() {
let payload = "evil\r\x1b[31mred\u{2028}line";
let log = capture(|| {
tracing::warn!(field = %payload, "message with \nnewline");
});
assert_eq!(
log.lines().count(),
1,
"a crafted field/message must not forge an extra log line: {log:?}"
);
assert!(!log.contains('\r'), "raw CR must not survive: {log:?}");
assert!(!log.contains('\x1b'), "raw ESC must not survive: {log:?}");
assert!(
!log.contains('\u{2028}'),
"raw line separator must not survive: {log:?}"
);
}
#[test]
fn skips_log_crate_bridge_fields() {
let log = capture(|| {
tracing::warn!(
log.file = "/home/builder/.cargo/registry/src/foo/connect.rs",
log.line = 929,
log.module_path = "reqwest::connect",
log.target = "reqwest::connect",
"bridged log-crate event"
);
});
assert!(
!log.contains("log.file"),
"log.* bridge fields must be skipped, not rendered: {log:?}"
);
assert!(
!log.contains(".cargo/registry"),
"the build machine's absolute path must not leak: {log:?}"
);
assert!(
log.contains("bridged log-crate event"),
"the event's own message must still render: {log:?}"
);
}
#[test]
fn sanitizes_span_field_control_chars() {
let log = capture(|| {
let span = tracing::info_span!("crafted-span", value = %"evil\nvalue");
let _enter = span.enter();
tracing::warn!("event inside span");
});
assert_eq!(
log.lines().count(),
1,
"a crafted span field must not forge an extra log line: {log:?}"
);
}
}