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