1use std::path::Path;
2use std::process::{Command, ExitCode, Stdio};
3
4use crate::cli::{AuditCommand, Cli};
5use crate::config::{LoadOptions, load_config};
6use crate::error::SboxError;
7use crate::exec::status_to_exit_code;
8
9pub enum InlineAuditStatus {
12 Clean,
14 Findings,
16 ToolNotFound,
18 Error,
20}
21
22pub struct InlineAuditResult {
23 pub pm_name: String,
24 pub tool: &'static str,
25 pub status: InlineAuditStatus,
26 pub output: String,
28}
29
30pub(crate) fn run_inline(pm_name: &str, workspace_root: &Path) -> InlineAuditResult {
33 let (program, base_args, _hint) = audit_command_for(pm_name);
34
35 let result = Command::new(program)
36 .args(base_args)
37 .current_dir(workspace_root)
38 .stdin(Stdio::null())
39 .stdout(Stdio::piped())
40 .stderr(Stdio::piped())
41 .output();
42
43 match result {
44 Err(e) if e.kind() == std::io::ErrorKind::NotFound => InlineAuditResult {
45 pm_name: pm_name.to_string(),
46 tool: program,
47 status: InlineAuditStatus::ToolNotFound,
48 output: format!(
49 "`{program}` is not installed — install it and re-run `sbox plan` \
50 or run `sbox audit` directly."
51 ),
52 },
53 Err(_) => InlineAuditResult {
54 pm_name: pm_name.to_string(),
55 tool: program,
56 status: InlineAuditStatus::Error,
57 output: format!("`{program}` could not be launched."),
58 },
59 Ok(out) => {
60 let combined = format!(
61 "{}{}",
62 String::from_utf8_lossy(&out.stdout),
63 String::from_utf8_lossy(&out.stderr)
64 );
65 let truncated = if combined.len() > 2_000 {
66 format!(
67 "{}… (truncated — run `sbox audit` for full output)",
68 combined[..2_000].trim_end()
69 )
70 } else {
71 combined
72 };
73 InlineAuditResult {
74 pm_name: pm_name.to_string(),
75 tool: program,
76 status: if out.status.success() {
77 InlineAuditStatus::Clean
78 } else {
79 InlineAuditStatus::Findings
80 },
81 output: truncated,
82 }
83 }
84 }
85}
86
87pub fn execute(cli: &Cli, command: &AuditCommand) -> Result<ExitCode, SboxError> {
93 let loaded = load_config(&LoadOptions {
94 workspace: cli.workspace.clone(),
95 config: cli.config.clone(),
96 })?;
97
98 let pm_name = loaded
99 .config
100 .package_manager
101 .as_ref()
102 .map(|pm| pm.name.as_str())
103 .unwrap_or_else(|| detect_pm_from_workspace(&loaded.workspace_root));
104
105 let (program, base_args, install_hint) = audit_command_for(pm_name);
106
107 let mut child = Command::new(program);
108 child.args(base_args);
109 child.args(&command.extra_args);
110 child.current_dir(&loaded.workspace_root);
111 child.stdin(Stdio::inherit());
112 child.stdout(Stdio::inherit());
113 child.stderr(Stdio::inherit());
114
115 match child.status() {
116 Ok(status) => Ok(status_to_exit_code(status)),
117 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
118 eprintln!("sbox audit: `{program}` not found.");
119 eprintln!("{install_hint}");
120 Ok(ExitCode::from(127))
121 }
122 Err(source) => Err(SboxError::CommandSpawn {
123 program: program.to_string(),
124 source,
125 }),
126 }
127}
128
129fn audit_command_for(pm_name: &str) -> (&'static str, &'static [&'static str], &'static str) {
131 match pm_name {
132 "npm" => (
133 "npm",
134 &["audit"] as &[&str],
135 "npm is required. Install Node.js from https://nodejs.org",
136 ),
137 "yarn" => (
138 "yarn",
139 &["npm", "audit"],
140 "yarn is required. Install from https://yarnpkg.com",
141 ),
142 "pnpm" => ("pnpm", &["audit"], "pnpm is required: npm install -g pnpm"),
143 "bun" => (
144 "npm",
147 &["audit"],
148 "npm is required for bun audit. Install Node.js from https://nodejs.org",
149 ),
150 "uv" | "pip" | "poetry" => (
151 "pip-audit",
152 &[] as &[&str],
153 "pip-audit is required: pip install pip-audit or uv tool install pip-audit",
154 ),
155 "cargo" => (
156 "cargo",
157 &["audit"],
158 "cargo-audit is required: cargo install cargo-audit",
159 ),
160 "go" => (
161 "govulncheck",
162 &["./..."],
163 "govulncheck is required: go install golang.org/x/vuln/cmd/govulncheck@latest",
164 ),
165 _ => (
166 "npm",
167 &["audit"],
168 "unknown package manager; defaulting to npm audit",
169 ),
170 }
171}
172
173pub(crate) fn detect_pm_from_workspace(root: &Path) -> &'static str {
176 if root.join("package-lock.json").exists() || root.join("npm-shrinkwrap.json").exists() {
177 return "npm";
178 }
179 if root.join("yarn.lock").exists() {
180 return "yarn";
181 }
182 if root.join("pnpm-lock.yaml").exists() {
183 return "pnpm";
184 }
185 if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
186 return "bun";
187 }
188 if root.join("uv.lock").exists() {
189 return "uv";
190 }
191 if root.join("poetry.lock").exists() {
192 return "poetry";
193 }
194 if root.join("requirements.txt").exists() {
195 return "pip";
196 }
197 if root.join("Cargo.lock").exists() {
198 return "cargo";
199 }
200 if root.join("go.sum").exists() {
201 return "go";
202 }
203 "npm"
204}