pub mod env_script;
pub mod step_script;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use openjd_expr::function_library::FunctionLibrary;
use openjd_expr::ExprValue;
use openjd_model::job::Action;
use openjd_model::job::CancelationMode;
use openjd_model::symbol_table::SymbolTable;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::action::ActionMessage;
use crate::action::ActionState;
use crate::action_filter::ActionFilter;
use crate::error::SessionError;
use crate::logging::log_subsection_banner;
use crate::session_user::SessionUser;
use crate::subprocess::{run_subprocess, SubprocessConfig, SubprocessResult};
#[derive(Debug, Clone)]
pub enum CancelMethod {
Terminate,
NotifyThenTerminate { terminate_delay: Duration },
}
impl std::fmt::Display for CancelMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Terminate => write!(f, "Terminate"),
Self::NotifyThenTerminate { terminate_delay } => {
write!(f, "NotifyThenTerminate({}s)", terminate_delay.as_secs())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptRunnerState {
Ready,
Running,
Canceling,
Canceled,
Timeout,
Failed,
Success,
}
impl std::fmt::Display for ScriptRunnerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ready => write!(f, "Ready"),
Self::Running => write!(f, "Running"),
Self::Canceling => write!(f, "Canceling"),
Self::Canceled => write!(f, "Canceled"),
Self::Timeout => write!(f, "Timeout"),
Self::Failed => write!(f, "Failed"),
Self::Success => write!(f, "Success"),
}
}
}
pub(crate) struct ScriptRunnerBase {
pub state: ScriptRunnerState,
pub cancel_token: CancellationToken,
pub cancel_request_rx: Option<tokio::sync::watch::Receiver<Option<Duration>>>,
pub session_id: String,
pub working_directory: PathBuf,
pub files_directory: PathBuf,
pub helpers_directory: Option<PathBuf>,
pub user: Option<Arc<dyn SessionUser>>,
pub redactions_enabled: bool,
pub initial_redacted_values: Vec<String>,
pub debug_collect_stdout: bool,
pub echo_openjd_directives: bool,
pub limits: crate::limits::SessionLimits,
#[cfg(unix)]
pub helper: Option<crate::cross_user_helper::CrossUserHelper>,
#[cfg(windows)]
pub helper: Option<crate::cross_user_helper::CrossUserHelperWin>,
pub cancel_writer: Option<std::fs::File>,
}
impl ScriptRunnerBase {
pub fn new(
session_id: &str,
working_directory: PathBuf,
files_directory: PathBuf,
user: Option<Arc<dyn SessionUser>>,
) -> Self {
Self {
state: ScriptRunnerState::Ready,
cancel_token: CancellationToken::new(),
cancel_request_rx: None,
session_id: session_id.to_string(),
working_directory,
files_directory,
helpers_directory: None,
user,
redactions_enabled: false,
initial_redacted_values: Vec::new(),
debug_collect_stdout: false,
echo_openjd_directives: true,
limits: crate::limits::SessionLimits::default(),
helper: None,
cancel_writer: None,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn run_action(
&mut self,
action: &Action,
symtab: &SymbolTable,
library: Option<&FunctionLibrary>,
env_vars: &HashMap<String, Option<String>>,
message_tx: mpsc::UnboundedSender<ActionMessage>,
default_timeout: Option<Duration>,
default_cancel_period: Duration,
) -> Result<SubprocessResult, SessionError> {
self.state = ScriptRunnerState::Running;
log_subsection_banner(&self.session_id, "Phase: Running action");
let args = resolve_action_args(action, symtab, library, &self.limits)?;
let timeout =
resolve_action_timeout(action, symtab, library, &self.limits, default_timeout)?;
let cancel_method = cancel_method_for_action(
&action.cancelation,
symtab,
library,
&self.limits,
default_cancel_period,
)?;
let config = SubprocessConfig {
args,
env_vars: env_vars.clone(),
working_dir: Some(self.working_directory.clone()),
timeout,
user: self.user.clone(),
cancel_method,
cancel_request_rx: self.cancel_request_rx.clone(),
debug_collect_stdout: self.debug_collect_stdout,
};
let mut filter = ActionFilter::new(
&self.session_id,
self.echo_openjd_directives,
self.redactions_enabled,
);
filter.add_redacted_values(&self.initial_redacted_values);
if let Some(ref mut helper) = self.helper {
let result = crate::cross_user_helper::run_via_helper(
helper,
&config,
&mut filter,
&self.session_id,
message_tx,
self.cancel_writer.as_ref(),
)
.await?;
self.state = state_from_action(result.state);
return Ok(result);
}
let result = run_subprocess(
config,
&mut filter,
&self.session_id,
message_tx,
self.cancel_token.clone(),
)
.await?;
self.state = state_from_action(result.state);
Ok(result)
}
}
fn state_from_action(action_state: ActionState) -> ScriptRunnerState {
match action_state {
ActionState::Success => ScriptRunnerState::Success,
ActionState::Canceled => ScriptRunnerState::Canceled,
ActionState::Timeout => ScriptRunnerState::Timeout,
_ => ScriptRunnerState::Failed,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EffectiveCancelation {
Undeclared,
Terminate,
NotifyThenTerminate {
notify_period_seconds: Option<i64>,
},
}
pub(crate) fn resolve_effective_cancelation(
cancelation: &Option<CancelationMode>,
symtab: &SymbolTable,
library: Option<&FunctionLibrary>,
limits: &crate::limits::SessionLimits,
) -> Result<EffectiveCancelation, SessionError> {
match cancelation {
None => Ok(EffectiveCancelation::Undeclared),
Some(CancelationMode::Terminate) => Ok(EffectiveCancelation::Terminate),
Some(CancelationMode::NotifyThenTerminate {
notify_period_in_seconds,
}) => Ok(EffectiveCancelation::NotifyThenTerminate {
notify_period_seconds: resolve_notify_period_seconds(
notify_period_in_seconds.as_ref(),
symtab,
library,
limits,
)?,
}),
Some(CancelationMode::DeferredMode {
mode,
notify_period_in_seconds,
}) => {
let target = openjd_expr::ExprType::union(vec![
openjd_expr::ExprType::STRING,
openjd_expr::ExprType::NULLTYPE,
]);
let value = mode
.resolve_with(
symtab,
&crate::limits::fs_options(library, limits).with_target_type(&target),
)
.map_err(|e| SessionError::FormatString {
context: "cancelation mode".into(),
reason: e.to_string(),
})?;
match value {
openjd_expr::ExprValue::Null => {
Ok(EffectiveCancelation::Undeclared)
}
openjd_expr::ExprValue::String(s) if s == "TERMINATE" => {
let period = resolve_notify_period_seconds(
notify_period_in_seconds.as_ref(),
symtab,
library,
limits,
)?;
if period.is_some() {
return Err(SessionError::FormatString {
context: "cancelation mode".into(),
reason: "mode resolved to TERMINATE, which does not accept \
notifyPeriodInSeconds"
.into(),
});
}
Ok(EffectiveCancelation::Terminate)
}
openjd_expr::ExprValue::String(s) if s == "NOTIFY_THEN_TERMINATE" => {
Ok(EffectiveCancelation::NotifyThenTerminate {
notify_period_seconds: resolve_notify_period_seconds(
notify_period_in_seconds.as_ref(),
symtab,
library,
limits,
)?,
})
}
other => Err(SessionError::FormatString {
context: "cancelation mode".into(),
reason: format!(
"must resolve to TERMINATE, NOTIFY_THEN_TERMINATE, or null; got {other:?}"
),
}),
}
}
}
}
pub(crate) fn cancel_method_for_action(
cancelation: &Option<CancelationMode>,
symtab: &SymbolTable,
library: Option<&FunctionLibrary>,
limits: &crate::limits::SessionLimits,
default_notify_period: Duration,
) -> Result<CancelMethod, SessionError> {
Ok(
match resolve_effective_cancelation(cancelation, symtab, library, limits)? {
EffectiveCancelation::Undeclared | EffectiveCancelation::Terminate => {
CancelMethod::Terminate
}
EffectiveCancelation::NotifyThenTerminate {
notify_period_seconds,
} => CancelMethod::NotifyThenTerminate {
terminate_delay: notify_period_seconds
.map(|n| Duration::from_secs(n as u64))
.unwrap_or(default_notify_period),
},
},
)
}
pub(crate) fn resolve_action_timeout(
action: &Action,
symtab: &SymbolTable,
library: Option<&FunctionLibrary>,
limits: &crate::limits::SessionLimits,
default: Option<Duration>,
) -> Result<Option<Duration>, SessionError> {
match &action.timeout {
Some(fmt) => {
let target = openjd_expr::ExprType::union(vec![
openjd_expr::ExprType::INT,
openjd_expr::ExprType::NULLTYPE,
]);
let value = fmt
.resolve_with(
symtab,
&crate::limits::fs_options(library, limits).with_target_type(&target),
)
.map_err(|e| SessionError::FormatString {
context: "timeout".into(),
reason: e.to_string(),
})?;
let secs: u64 = match value {
openjd_expr::ExprValue::Null => return Ok(default),
openjd_expr::ExprValue::Int(n) => {
if n <= 0 {
return Err(SessionError::FormatString {
context: "timeout".into(),
reason: format!("timeout must be a positive integer, got '{n}'"),
});
}
n as u64
}
openjd_expr::ExprValue::String(ref s) => match s.trim().parse::<u64>() {
Ok(n) if n > 0 => n,
_ => {
return Err(SessionError::FormatString {
context: "timeout".into(),
reason: format!("timeout must be a positive integer, got '{s}'"),
})
}
},
other => {
return Err(SessionError::FormatString {
context: "timeout".into(),
reason: format!("timeout must be a positive integer, got {other:?}"),
})
}
};
Ok(Some(Duration::from_secs(secs)))
}
None => Ok(default),
}
}
pub(crate) fn resolve_notify_period_seconds(
fs: Option<&openjd_model::FormatString>,
symtab: &SymbolTable,
library: Option<&FunctionLibrary>,
limits: &crate::limits::SessionLimits,
) -> Result<Option<i64>, SessionError> {
let Some(fs) = fs else {
return Ok(None);
};
let target = openjd_expr::ExprType::union(vec![
openjd_expr::ExprType::INT,
openjd_expr::ExprType::NULLTYPE,
]);
let value = fs
.resolve_with(
symtab,
&crate::limits::fs_options(library, limits).with_target_type(&target),
)
.map_err(|e| SessionError::FormatString {
context: "notifyPeriodInSeconds".into(),
reason: e.to_string(),
})?;
let n: i64 = match value {
openjd_expr::ExprValue::Null => return Ok(None),
openjd_expr::ExprValue::Int(n) => n,
openjd_expr::ExprValue::String(s) => {
s.trim().parse().map_err(|_| SessionError::FormatString {
context: "notifyPeriodInSeconds".into(),
reason: format!("notifyPeriodInSeconds must be a positive integer, got '{s}'"),
})?
}
other => {
return Err(SessionError::FormatString {
context: "notifyPeriodInSeconds".into(),
reason: format!("notifyPeriodInSeconds must be a positive integer, got {other:?}"),
})
}
};
if n <= 0 {
return Err(SessionError::FormatString {
context: "notifyPeriodInSeconds".into(),
reason: format!("notifyPeriodInSeconds must be positive, got '{n}'"),
});
}
if n > 600 {
return Err(SessionError::FormatString {
context: "notifyPeriodInSeconds".into(),
reason: format!("notifyPeriodInSeconds must not exceed 600, got '{n}'"),
});
}
Ok(Some(n))
}
pub(crate) fn resolve_action_args(
action: &Action,
symtab: &SymbolTable,
library: Option<&FunctionLibrary>,
limits: &crate::limits::SessionLimits,
) -> Result<Vec<String>, SessionError> {
let opts = crate::limits::fs_options(library, limits);
let check_len = |context: String, s: &str| -> Result<(), SessionError> {
if let Some(max_len) = limits.max_resolved_arg_len {
let n = s.chars().count();
if n > max_len {
return Err(SessionError::FormatString {
context,
reason: format!(
"resolved value is {n} characters, exceeding the maximum of {max_len}."
),
});
}
}
Ok(())
};
let command = action
.command
.resolve_string_with(symtab, &opts)
.map_err(|e| SessionError::FormatString {
context: "command".into(),
reason: e.to_string(),
})?;
check_len("command".into(), &command)?;
let mut args = vec![command];
if let Some(arg_fmts) = &action.args {
for (j, fs) in arg_fmts.iter().enumerate() {
if let Ok(val) = fs.resolve_with(symtab, &opts) {
match val {
ExprValue::Null => continue,
val if val.is_list() => {
if let Some(elements) = val.list_elements() {
for elem in &elements {
let s = elem.to_display_string();
check_len(format!("args[{j}]"), &s)?;
args.push(s);
}
}
continue;
}
val => {
let s = val.to_display_string();
check_len(format!("args[{j}]"), &s)?;
args.push(s);
}
}
} else {
let s = fs.resolve_string_with(symtab, &opts).map_err(|e| {
SessionError::FormatString {
context: "argument".into(),
reason: e.to_string(),
}
})?;
check_len(format!("args[{j}]"), &s)?;
args.push(s);
}
}
}
Ok(args)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn script_runner_state_display() {
assert_eq!(ScriptRunnerState::Ready.to_string(), "Ready");
assert_eq!(ScriptRunnerState::Running.to_string(), "Running");
assert_eq!(ScriptRunnerState::Canceling.to_string(), "Canceling");
assert_eq!(ScriptRunnerState::Canceled.to_string(), "Canceled");
assert_eq!(ScriptRunnerState::Timeout.to_string(), "Timeout");
assert_eq!(ScriptRunnerState::Failed.to_string(), "Failed");
assert_eq!(ScriptRunnerState::Success.to_string(), "Success");
}
#[test]
fn cancel_method_display() {
assert_eq!(CancelMethod::Terminate.to_string(), "Terminate");
assert_eq!(
CancelMethod::NotifyThenTerminate {
terminate_delay: Duration::from_secs(30)
}
.to_string(),
"NotifyThenTerminate(30s)"
);
}
fn fs(s: &str) -> openjd_model::FormatString {
openjd_model::FormatString::new(s).unwrap()
}
fn action_with_timeout(timeout: &str) -> Action {
Action {
command: fs("echo"),
args: None,
timeout: Some(fs(timeout)),
cancelation: None,
}
}
#[test]
fn timeout_nonpositive_int_reports_the_value() {
let symtab = SymbolTable::default();
for (expr, shown) in [
("{{ 0 }}", "'0'"),
("{{ '0' }}", "'0'"),
("{{ 0 - 5 }}", "'-5'"),
] {
let err = resolve_action_timeout(
&action_with_timeout(expr),
&symtab,
None,
&Default::default(),
None,
)
.unwrap_err();
assert_eq!(
err.to_string(),
format!(
"Failed to resolve timeout: timeout must be a positive integer, got {shown}"
),
"expr {expr}"
);
}
}
#[test]
fn timeout_positive_and_null_resolve() {
let symtab = SymbolTable::default();
assert_eq!(
resolve_action_timeout(
&action_with_timeout("{{ 90 }}"),
&symtab,
None,
&Default::default(),
None
)
.unwrap(),
Some(Duration::from_secs(90))
);
assert_eq!(
resolve_action_timeout(
&action_with_timeout("{{ null }}"),
&symtab,
None,
&Default::default(),
Some(Duration::from_secs(30))
)
.unwrap(),
Some(Duration::from_secs(30))
);
}
#[test]
fn notify_period_resolves_literal_and_bounds() {
let symtab = SymbolTable::default();
assert_eq!(
resolve_notify_period_seconds(Some(&fs("45")), &symtab, None, &Default::default())
.unwrap(),
Some(45)
);
assert_eq!(
resolve_notify_period_seconds(None, &symtab, None, &Default::default()).unwrap(),
None
);
let err =
resolve_notify_period_seconds(Some(&fs("9999")), &symtab, None, &Default::default())
.unwrap_err();
assert!(
err.to_string().contains("must not exceed 600"),
"expected cap error; got: {err}"
);
assert!(
resolve_notify_period_seconds(Some(&fs("0")), &symtab, None, &Default::default())
.is_err()
);
}
#[test]
fn notify_period_whole_field_null_is_none() {
let mut symtab = SymbolTable::default();
symtab
.set("X", openjd_expr::ExprValue::Null)
.expect("symtab");
assert_eq!(
resolve_notify_period_seconds(Some(&fs("{{X}}")), &symtab, None, &Default::default())
.unwrap(),
None
);
}
fn action_with_args(command: &str, args: &[&str]) -> Action {
Action {
command: fs(command),
args: Some(args.iter().map(|a| fs(a)).collect()),
timeout: None,
cancelation: None,
}
}
fn arg_cap(n: usize) -> crate::limits::SessionLimits {
crate::limits::SessionLimits {
max_resolved_arg_len: Some(n),
..Default::default()
}
}
#[test]
fn resolved_command_over_arg_cap_rejected() {
let symtab = SymbolTable::default();
let action = action_with_args("{{ 'A' * 200 }}", &[]);
let err = resolve_action_args(&action, &symtab, None, &arg_cap(100)).unwrap_err();
assert_eq!(
err.to_string(),
"Failed to resolve command: resolved value is 200 characters, exceeding the maximum of 100."
);
}
#[test]
fn resolved_arg_over_cap_rejected_with_index() {
let symtab = SymbolTable::default();
let action = action_with_args("echo", &["ok", "{{ 'A' * 200 }}"]);
let err = resolve_action_args(&action, &symtab, None, &arg_cap(100)).unwrap_err();
assert_eq!(
err.to_string(),
"Failed to resolve args[1]: resolved value is 200 characters, exceeding the maximum of 100."
);
}
#[test]
fn flattened_list_arg_elements_each_checked() {
let symtab = SymbolTable::default();
let action = action_with_args("echo", &["{{ ['A' * 150] * 2 }}"]);
let err = resolve_action_args(&action, &symtab, None, &arg_cap(100)).unwrap_err();
assert_eq!(
err.to_string(),
"Failed to resolve args[0]: resolved value is 150 characters, exceeding the maximum of 100."
);
let action = action_with_args("echo", &["{{ ['A' * 90] * 3 }}"]);
let args = resolve_action_args(&action, &symtab, None, &arg_cap(100)).unwrap();
assert_eq!(args.len(), 4); }
#[test]
fn args_at_cap_and_uncapped_pass() {
let symtab = SymbolTable::default();
let action = action_with_args("echo", &["{{ 'A' * 100 }}"]);
assert!(resolve_action_args(&action, &symtab, None, &arg_cap(100)).is_ok());
let action = action_with_args("echo", &["{{ 'A' * 200 }}"]);
assert!(resolve_action_args(&action, &symtab, None, &Default::default()).is_ok());
}
#[test]
fn arg_cap_measures_characters_not_bytes() {
let symtab = SymbolTable::default();
let action = action_with_args("echo", &[&"\u{4e16}".repeat(100)]);
assert!(resolve_action_args(&action, &symtab, None, &arg_cap(100)).is_ok());
let action = action_with_args("echo", &[&"\u{4e16}".repeat(101)]);
let err = resolve_action_args(&action, &symtab, None, &arg_cap(100)).unwrap_err();
assert_eq!(
err.to_string(),
"Failed to resolve args[0]: resolved value is 101 characters, exceeding the maximum of 100."
);
}
#[test]
fn session_limits_from_caller_limits_mirrors_runtime_fields() {
let caller = openjd_model::CallerLimits {
max_resolved_arg_len: Some(1),
max_resolved_data_len: Some(2),
max_eval_memory_bytes: Some(3),
max_eval_operations: Some(4),
max_step_count: Some(99), ..Default::default()
};
let session: crate::limits::SessionLimits = (&caller).into();
assert_eq!(
session,
crate::limits::SessionLimits {
max_resolved_arg_len: Some(1),
max_resolved_data_len: Some(2),
max_eval_memory_bytes: Some(3),
max_eval_operations: Some(4),
}
);
}
#[test]
fn arg_evaluation_respects_memory_budget() {
let symtab = SymbolTable::default();
let limits = crate::limits::SessionLimits {
max_eval_memory_bytes: Some(1000),
..Default::default()
};
let action = action_with_args("echo", &["{{ 'A' * 100000 }}"]);
let err = resolve_action_args(&action, &symtab, None, &limits).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("exceeded limit (1000 bytes)"),
"expected memory-budget error; got: {msg}"
);
}
#[test]
fn arg_evaluation_respects_operation_budget() {
let symtab = SymbolTable::default();
let limits = crate::limits::SessionLimits {
max_eval_operations: Some(50),
..Default::default()
};
let action = action_with_args("echo", &["{{ sum([1] * 1000) }}"]);
let err = resolve_action_args(&action, &symtab, None, &limits).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("exceeded limit (50)"),
"expected operation-budget error; got: {msg}"
);
}
}