Skip to main content

bitwarden_access_connector/integrations/scripting/
outcome.rs

1//! Translation of a script run into the rotation outcome the vault is told about.
2
3use bitwarden_pam::RotationFailureCode;
4
5use super::{
6    Operation,
7    runner::{InvokeError, ScriptExit},
8};
9use crate::{
10    error::{Retry, SafeDetail},
11    integrations::{IntegrationError, TargetEffect},
12};
13
14/// Translates a [`ScriptRunner::run`](super::runner::ScriptRunner::run) result into a rotation
15/// outcome, implementing the README's exit-code and timeout tables.
16pub(crate) fn classify_outcome(
17    outcome: Result<ScriptExit, InvokeError>,
18    operation: Operation,
19    timeout_secs: u64,
20) -> Result<(), IntegrationError> {
21    match outcome {
22        Ok(ScriptExit::Code(0)) => Ok(()),
23        Ok(exit) => Err(exit_error(exit, operation)),
24        Err(InvokeError::Timeout) => Err(timeout_error(operation, timeout_secs)),
25        Err(e @ InvokeError::StdinWrite(_)) => {
26            Err(script_io_error(operation, "ScriptStdinWriteFailed").with_source(e))
27        }
28        Err(e @ InvokeError::Wait(_)) => {
29            Err(script_io_error(operation, "ScriptWaitFailed").with_source(e))
30        }
31        Err(e @ (InvokeError::Serialize(_) | InvokeError::Spawn(_))) => Err(IntegrationError::new(
32            Retry::Permanent,
33            TargetEffect::NotApplied,
34            RotationFailureCode::Internal,
35            SafeDetail::from_kind("ScriptSpawnError"),
36        )
37        .with_source(e)),
38    }
39}
40
41fn script_io_error(operation: Operation, kind: &'static str) -> IntegrationError {
42    IntegrationError::new(
43        Retry::Permanent,
44        operation.effect_if_interrupted(),
45        RotationFailureCode::Internal,
46        SafeDetail::from_kind(kind),
47    )
48}
49
50fn timeout_error(operation: Operation, timeout_secs: u64) -> IntegrationError {
51    IntegrationError::new(
52        Retry::Permanent,
53        operation.effect_on_timeout(),
54        RotationFailureCode::ScriptTimeout,
55        SafeDetail::timed_out(timeout_secs),
56    )
57}
58
59fn exit_error(exit: ScriptExit, operation: Operation) -> IntegrationError {
60    let (retry, effect, detail) = match exit {
61        ScriptExit::Code(1) => (
62            Retry::Permanent,
63            TargetEffect::NotApplied,
64            SafeDetail::from_exit_code(1),
65        ),
66        ScriptExit::Code(2) => (
67            Retry::Permanent,
68            TargetEffect::Applied,
69            SafeDetail::from_exit_code(2),
70        ),
71        ScriptExit::Code(3) => (
72            Retry::Permanent,
73            TargetEffect::Unknown,
74            SafeDetail::from_exit_code(3),
75        ),
76        ScriptExit::Code(4) => (
77            Retry::Transient,
78            TargetEffect::NotApplied,
79            SafeDetail::from_exit_code(4),
80        ),
81        // Unknown exit code or signal termination.
82        other => {
83            let detail = match other {
84                ScriptExit::Code(n) => SafeDetail::from_exit_code(n),
85                ScriptExit::Signal(Some(n)) => SafeDetail::from_signal(n),
86                ScriptExit::Signal(None) => SafeDetail::from_kind("signal"),
87            };
88            (Retry::Permanent, operation.effect_if_interrupted(), detail)
89        }
90    };
91    IntegrationError::new(retry, effect, RotationFailureCode::ScriptFailed, detail)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn io_error() -> std::io::Error {
99        std::io::Error::other("test io failure")
100    }
101
102    #[test]
103    fn classify_outcome_maps_the_exit_code_table() {
104        let cases = [
105            (1, Retry::Permanent, TargetEffect::NotApplied),
106            (2, Retry::Permanent, TargetEffect::Applied),
107            (3, Retry::Permanent, TargetEffect::Unknown),
108            (4, Retry::Transient, TargetEffect::NotApplied),
109        ];
110        for (code, retry, effect) in cases {
111            let err =
112                classify_outcome(Ok(ScriptExit::Code(code)), Operation::Rotate, 60).unwrap_err();
113            assert_eq!(err.retry, retry, "exit {code}");
114            assert_eq!(err.effect, effect, "exit {code}");
115            assert_eq!(err.code, RotationFailureCode::ScriptFailed, "exit {code}");
116        }
117        classify_outcome(Ok(ScriptExit::Code(0)), Operation::Rotate, 60).unwrap();
118    }
119
120    #[test]
121    fn classify_outcome_reports_the_signal_number() {
122        let err =
123            classify_outcome(Ok(ScriptExit::Signal(Some(9))), Operation::Rotate, 60).unwrap_err();
124        assert_eq!(err.retry, Retry::Permanent);
125        assert_eq!(err.effect, TargetEffect::Unknown);
126        assert_eq!(err.code, RotationFailureCode::ScriptFailed);
127        assert_eq!(err.detail.as_str(), "signal 9");
128    }
129
130    #[test]
131    fn classify_outcome_names_a_signal_without_a_number() {
132        let err =
133            classify_outcome(Ok(ScriptExit::Signal(None)), Operation::Rotate, 60).unwrap_err();
134        assert_eq!(err.detail.as_str(), "error kind: signal");
135    }
136
137    #[test]
138    fn classify_outcome_signal_leaves_a_non_rotate_target_alone() {
139        let err =
140            classify_outcome(Ok(ScriptExit::Signal(Some(9))), Operation::Verify, 60).unwrap_err();
141        assert_eq!(err.effect, TargetEffect::NotApplied);
142    }
143
144    #[test]
145    fn classify_outcome_unknown_exit_code_effect_depends_on_the_operation() {
146        for (operation, effect) in [
147            (Operation::Rotate, TargetEffect::Unknown),
148            (Operation::Verify, TargetEffect::NotApplied),
149            (Operation::Terminate, TargetEffect::NotApplied),
150        ] {
151            let err = classify_outcome(Ok(ScriptExit::Code(99)), operation, 60).unwrap_err();
152            assert_eq!(err.effect, effect, "{operation:?}");
153        }
154    }
155
156    #[test]
157    fn classify_outcome_timeout_effect_depends_on_the_operation() {
158        for (operation, effect) in [
159            (Operation::Rotate, TargetEffect::Unknown),
160            (Operation::Verify, TargetEffect::Applied),
161            (Operation::Terminate, TargetEffect::NotApplied),
162        ] {
163            let err = classify_outcome(Err(InvokeError::Timeout), operation, 60).unwrap_err();
164            assert_eq!(
165                err.code,
166                RotationFailureCode::ScriptTimeout,
167                "{operation:?}"
168            );
169            assert_eq!(err.effect, effect, "{operation:?}");
170        }
171    }
172
173    #[test]
174    fn classify_outcome_does_not_claim_rotate_left_the_target_alone() {
175        let errors: [fn() -> InvokeError; 2] = [
176            || InvokeError::StdinWrite(io_error()),
177            || InvokeError::Wait(io_error()),
178        ];
179        for make in errors {
180            let err = make();
181            let rotate = classify_outcome(Err(make()), Operation::Rotate, 60).unwrap_err();
182            assert_eq!(rotate.effect, TargetEffect::Unknown, "{err:?}");
183
184            for operation in [Operation::Verify, Operation::Terminate] {
185                let other = classify_outcome(Err(make()), operation, 60).unwrap_err();
186                assert_eq!(
187                    other.effect,
188                    TargetEffect::NotApplied,
189                    "{err:?}/{operation:?}"
190                );
191            }
192        }
193    }
194
195    #[test]
196    fn classify_outcome_names_the_failing_stage() {
197        let write = classify_outcome(
198            Err(InvokeError::StdinWrite(io_error())),
199            Operation::Rotate,
200            60,
201        )
202        .unwrap_err();
203        assert_eq!(write.detail.as_str(), "error kind: ScriptStdinWriteFailed");
204
205        let wait = classify_outcome(Err(InvokeError::Wait(io_error())), Operation::Rotate, 60)
206            .unwrap_err();
207        assert_eq!(wait.detail.as_str(), "error kind: ScriptWaitFailed");
208    }
209
210    #[test]
211    fn classify_outcome_reports_a_spawn_failure_as_internal() {
212        let spawn = classify_outcome(Err(InvokeError::Spawn(io_error())), Operation::Rotate, 60)
213            .unwrap_err();
214        assert_eq!(spawn.code, RotationFailureCode::Internal);
215        assert_eq!(spawn.detail.as_str(), "error kind: ScriptSpawnError");
216    }
217
218    #[test]
219    fn classify_outcome_keeps_the_invoke_error_as_the_source() {
220        for err in [
221            InvokeError::Spawn(io_error()),
222            InvokeError::StdinWrite(io_error()),
223            InvokeError::Wait(io_error()),
224        ] {
225            let err = classify_outcome(Err(err), Operation::Rotate, 60).unwrap_err();
226            assert!(std::error::Error::source(&err).is_some());
227        }
228    }
229}