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