Skip to main content

airs_cli_rs/
lib.rs

1//! `airs-cli-rs`: a native Prisma AIRS CLI built on the `scm-sdk` crate.
2//!
3//! [`run`] is the whole process: version pre-scan, argument parsing, usage
4//! validation, a current-thread Tokio runtime with signal-driven cancellation, the
5//! command, and the exit-code mapping (DESIGN §2).
6#![forbid(unsafe_code)]
7
8// Every module is private: the binary is the only consumer, and private modules keep
9// the dead-code lint working across the crate.
10mod bulk;
11mod cli;
12mod clients;
13mod commands;
14mod debug;
15mod dlp;
16mod env;
17mod error;
18mod files;
19mod input;
20mod json;
21mod modelsec;
22mod output;
23mod pagination;
24mod policy;
25mod redteam;
26mod render;
27mod report;
28mod signals;
29mod store;
30mod tenant;
31mod views;
32mod walk;
33mod writes;
34
35use clap::FromArgMatches;
36use commands::{Invocation, Plan};
37use error::{CliError, EXIT_OK, EXIT_OPERATIONAL, EXIT_USAGE};
38use output::Output;
39use std::ffi::OsString;
40use std::io::{ErrorKind, Write};
41
42/// The bare version printed by `-V` / `--version`.
43pub const VERSION: &str = env!("CARGO_PKG_VERSION");
44
45/// Run the CLI with `args` (including the program name) and return the exit code.
46pub fn run<I, T>(args: I) -> i32
47where
48    I: IntoIterator<Item = T>,
49    T: Into<OsString>,
50{
51    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
52    if version_requested(&args) {
53        return print_version();
54    }
55    let parsed = cli::command()
56        .try_get_matches_from(&args)
57        .and_then(|matches| cli::Cli::from_arg_matches(&matches));
58    let cli = match parsed {
59        Ok(cli) => cli,
60        Err(error) => {
61            // Help goes to stdout (exit 0); parse errors to stderr (exit 2), without
62            // argument values for the commands that take secrets (D91).
63            let error = cli::redacted_parse_error(&args, &error).unwrap_or(error);
64            let _ = error.print();
65            return error.exit_code();
66        }
67    };
68    let mut output = Output::stdio(cli.global.quiet);
69    output.set_debug(cli.global.debug);
70    // The deprecated `--format` alias warns before any other validation (DESIGN §13.1).
71    if commands::uses_format_alias(&cli) {
72        output.warning(commands::FORMAT_DEPRECATED);
73    }
74    // So do the red team aliases (DESIGN §15.3 "Validation order").
75    if let Some(cli::TopCommand::Redteam(group)) = &cli.command {
76        for warning in commands::redteam::deprecation_warnings(group) {
77            output.warning(warning);
78        }
79    }
80    // So do the hidden `--size` and `--page` of the DLP lists (DESIGN §16.3).
81    if let Some(group) = commands::dlp_group(&cli) {
82        for warning in commands::dlp::deprecation_warnings(group) {
83            output.warning(warning);
84        }
85    }
86    // So does the hidden `--input` of `runtime bulk-scan` (DESIGN §14.1).
87    if commands::uses_input_alias(&cli) {
88        output.warning(commands::INPUT_DEPRECATED);
89    }
90    let plan = match commands::plan(&cli) {
91        Ok(plan) => plan,
92        Err(error) => {
93            output.report(&error);
94            return error.exit_code(None);
95        }
96    };
97    if let Plan::GroupHelp(path) = plan {
98        output.write_stderr(&cli::group_help(path));
99        return EXIT_USAGE;
100    }
101    let mut invocation = Invocation::new(output, cli.global.tenant.clone());
102    // After all usage validation, before the tenant is read (DESIGN §2.9).
103    if cli.global.debug
104        && plan.debug_policy() == debug::DebugPolicy::Log
105        && let Err(error) = invocation.open_debug_log()
106    {
107        invocation.output.report(&error);
108        return error.exit_code(None);
109    }
110    execute(plan, invocation)
111}
112
113/// `-V` / `--version` anywhere before a literal `--` (commander fires it during parse,
114/// even where an option value is expected).
115pub fn version_requested(args: &[OsString]) -> bool {
116    args.iter()
117        .skip(1)
118        .take_while(|arg| *arg != "--")
119        .any(|arg| arg == "-V" || arg == "--version")
120}
121
122/// The failure for a stdout write that failed for a reason other than a closed pipe.
123/// Fixed text: the OS error is never echoed.
124fn stdout_write_failed() -> CliError {
125    CliError::operational("AISEC_SDK_ERROR:Output could not be written to stdout")
126}
127
128fn print_version() -> i32 {
129    let mut stdout = std::io::stdout().lock();
130    match writeln!(stdout, "{VERSION}").and_then(|()| stdout.flush()) {
131        Err(error) if error.kind() != ErrorKind::BrokenPipe => {
132            let text = stdout_write_failed().render_colored(render::style::stderr_color());
133            let _ = std::io::stderr().write_all(text.as_bytes());
134            EXIT_OPERATIONAL
135        }
136        _ => EXIT_OK,
137    }
138}
139
140fn execute(plan: Plan, mut invocation: Invocation) -> i32 {
141    let runtime = match tokio::runtime::Builder::new_current_thread()
142        .enable_all()
143        .build()
144    {
145        Ok(runtime) => runtime,
146        Err(_) => {
147            let error = CliError::operational("AISEC_SDK_ERROR:The async runtime could not start");
148            invocation.output.report(&error);
149            return EXIT_OPERATIONAL;
150        }
151    };
152    let signals = signals::install(invocation.session.clone());
153    signals.set_quiet(invocation.output.quiet());
154    invocation.output.attach_signals(signals.clone());
155    let fallback = plan.interrupt_context();
156    let result = runtime.block_on(commands::dispatch(&mut invocation, plan));
157    // A tenant write that passed its commit point reports that the change was saved.
158    let fallback = if invocation.committed {
159        error::InterruptContext::ChangeSaved
160    } else {
161        invocation.interrupt.take().unwrap_or(fallback)
162    };
163    // Never wait for leftover blocking tasks (a slow `getaddrinfo` after a cancel or a
164    // deadline): the process exits right after `finish`.
165    runtime.shutdown_background();
166    let signal = signals.code();
167    // A signal always ends the run as interrupted; partial results are never written.
168    let result = match (result, signal) {
169        (Err(CliError::Interrupted(context)), _) => Err(CliError::Interrupted(context)),
170        (_, Some(_)) => Err(CliError::Interrupted(fallback)),
171        (result, None) => result,
172    };
173    finish(&mut invocation.output, result, signal)
174}
175
176fn finish(output: &mut Output, result: Result<(), CliError>, signal: Option<i32>) -> i32 {
177    match result {
178        Ok(()) => match output.commit() {
179            Ok(()) => EXIT_OK,
180            // Node's console ignores EPIPE; so do we.
181            Err(error) if error.kind() == ErrorKind::BrokenPipe => EXIT_OK,
182            Err(_) => {
183                // A signal that arrived just before the write: report the interruption.
184                let error = match output.pending_signal() {
185                    Some(_) => CliError::Interrupted(error::InterruptContext::OutputIncomplete),
186                    None => stdout_write_failed(),
187                };
188                output.report(&error);
189                error.exit_code(output.pending_signal())
190            }
191        },
192        Err(error) => {
193            output.discard();
194            output.report(&error);
195            error.exit_code(signal)
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn args(list: &[&str]) -> Vec<OsString> {
205        list.iter().map(OsString::from).collect()
206    }
207
208    #[test]
209    fn version_pre_scan() {
210        assert!(version_requested(&args(&["airs-cli-rs", "-V"])));
211        assert!(version_requested(&args(&[
212            "airs-cli-rs",
213            "runtime",
214            "scan",
215            "--profile",
216            "-V",
217            "hi"
218        ])));
219        assert!(version_requested(&args(&[
220            "airs-cli-rs",
221            "doctor",
222            "--version"
223        ])));
224        assert!(!version_requested(&args(&[
225            "airs-cli-rs",
226            "runtime",
227            "scan",
228            "--profile",
229            "p",
230            "--",
231            "-V"
232        ])));
233        assert!(
234            !version_requested(&args(&["-V"])),
235            "the program name is skipped"
236        );
237        assert!(!version_requested(&args(&["airs-cli-rs", "-v"])));
238    }
239
240    #[test]
241    fn finish_maps_results_to_exit_codes() {
242        let (mut out, stdout, stderr) = output::testing::capture(false);
243        out.data_line("data");
244        assert_eq!(finish(&mut out, Ok(()), None), 0);
245        assert_eq!(stdout.text(), "data\n");
246
247        let (mut out, stdout, stderr2) = output::testing::capture(false);
248        out.data_line("partial");
249        assert_eq!(finish(&mut out, Err(CliError::usage("bad")), None), 2);
250        assert_eq!(stdout.text(), "");
251        assert_eq!(stderr2.text(), "\n  ✗ bad\n\n");
252
253        let (mut out, _, stderr3) = output::testing::capture(false);
254        let code = finish(
255            &mut out,
256            Err(CliError::Interrupted(
257                error::InterruptContext::NoDataWritten,
258            )),
259            Some(143),
260        );
261        assert_eq!(code, 143);
262        assert!(stderr3.text().contains("Interrupted; no data was written"));
263        assert_eq!(stderr.text(), "");
264    }
265
266    #[test]
267    fn broken_pipe_is_success() {
268        struct Closed;
269        impl Write for Closed {
270            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
271                Err(std::io::Error::from(ErrorKind::BrokenPipe))
272            }
273            fn flush(&mut self) -> std::io::Result<()> {
274                Ok(())
275            }
276        }
277        let mut out = Output::with_sinks(false, Box::new(Closed), Box::new(std::io::sink()));
278        out.data_line("data");
279        assert_eq!(finish(&mut out, Ok(()), None), 0);
280    }
281
282    #[test]
283    fn other_stdout_write_failures_are_reported() {
284        struct Full;
285        impl Write for Full {
286            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
287                Err(std::io::Error::from(ErrorKind::StorageFull))
288            }
289            fn flush(&mut self) -> std::io::Result<()> {
290                Ok(())
291            }
292        }
293        let stderr = output::testing::Capture::default();
294        let mut out = Output::with_sinks(false, Box::new(Full), Box::new(stderr.clone()));
295        out.data_line("data");
296        assert_eq!(finish(&mut out, Ok(()), None), 1);
297        assert_eq!(
298            stderr.text(),
299            "\n  ✗ Error: AISEC_SDK_ERROR:Output could not be written to stdout\n\n"
300        );
301    }
302
303    #[test]
304    fn a_signal_before_the_final_write_reports_the_interruption() {
305        let (mut out, stdout, stderr) = output::testing::capture(false);
306        let signals = std::sync::Arc::new(signals::SignalState::default());
307        assert_eq!(signals.on_signal(143), signals::Action::Cancel);
308        out.attach_signals(signals);
309        out.data_line("data");
310        assert_eq!(finish(&mut out, Ok(()), None), 143);
311        assert_eq!(stdout.text(), "", "nothing is written after a signal");
312        assert_eq!(
313            stderr.text(),
314            "\n  ✗ Error: Interrupted; the output was not completely written\n\n"
315        );
316    }
317}