diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 9babc5a50a..8820439a0d 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -81,6 +81,8 @@ pub struct ActionsFile { pub actions: Vec, } +pub const MAX_PLAYBACK_STEPS: u64 = 100_000; + /// Actions list and the recording / playback flags. Only `list` is persisted. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -95,6 +97,9 @@ pub struct ActionState { /// Active action names, for cycle detection in engine and desktop playback. #[serde(skip)] pub playback_stack: Vec, + /// Number of steps executed by the current top-level playback tree. + #[serde(skip)] + pub playback_steps: u64, /// Bumped whenever `list` changes, so the preset store can skip unchanged writes. #[serde(skip)] pub rev: u64, @@ -143,6 +148,15 @@ pub fn save_target_closed(s: &Session, id: &str, target: Option bool { + if s.actions.playback_steps >= MAX_PLAYBACK_STEPS { + false + } else { + s.actions.playback_steps += 1; + true + } +} /// View-only menu commands recorded by the desktop shell. Headless playback reports /// them as unsupported instead of silently dropping them; Batch and droplets skip them. pub fn shell_view_command(id: &str) -> bool { @@ -348,6 +362,10 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option (u64, Option Result> { flush_recording(s)?; + if s.actions.playing == 0 { + s.actions.playback_steps = 0; + } let recording = s.actions.recording.take(); s.actions.playing = s.actions.playing.saturating_add(1); s.actions.playback_stack.push(action.name.clone()); @@ -1583,3 +1604,16 @@ mod tests { assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing"); } } + +#[cfg(test)] +mod security_tests { + use super::*; + + #[test] + fn playback_budget_is_shared_by_nested_calls() { + let mut session = Session::new(); + session.actions.playback_steps = MAX_PLAYBACK_STEPS - 1; + assert!(consume_playback_step(&mut session)); + assert!(!consume_playback_step(&mut session)); + } +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 0bb2b78928..1833d0a616 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1259,7 +1259,9 @@ fn batch(s: &mut Session, p: &Value) -> Result { let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() }; let names = batch_naming::plan(&inputs, &output, &format, p)?; let save = SaveOpts::from_params(s, p, cmd)?; - let run = |scratch: &mut Session| { + let authorize = s.authorize; + let run = move |scratch: &mut Session| { + scratch.authorize = authorize; scratch.actions = actions.clone(); for (id, params) in &steps { let r = scratch.execute(id, params.clone())?; diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 8a7ce9bf39..a9fbc56add 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -101,6 +101,10 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result