diff --git a/docs/running-the-scripted-client.md b/docs/running-the-scripted-client.md index ec05dd2..f33eb62 100644 --- a/docs/running-the-scripted-client.md +++ b/docs/running-the-scripted-client.md @@ -5,10 +5,14 @@ provided in this repo can be run in a scripted mode. Doing so requires writing a prompt sequence file specifying the prompts that expected to be seen and how they should be actioned. -## Running the scripted client from the command line +# Running the scripted client from the command line + The scripted client can be invoked using the `scripted` subcommand as shown below: ```bash -$ apparmor-prompting scripted --script my-script.json +$ apparmor-prompting.scripted \ + --script my-script.json \ + --var "MY_VAR:foo" \ + --var "MY_OTHER_VAR:bar" ``` The JSON script file provided as an argument must parse as a valid prompt @@ -18,19 +22,24 @@ if the sequence completes successfully and exiting non-0 if there are any errors. -## Writing a prompt sequence +# Writing a prompt sequence A prompt sequence is a simple JSON file with the following structure: ```json { "version": 1, + "prompt-filter": { + "snap": "snap-name", + "interface": "home", + "constraints": { + "path": "$BASE_PATH/.*" + } + }, "prompts": [ { "prompt-filter": { - "snap": "snap-name", - "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/example.txt", + "path": ".*/example.txt", "permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } @@ -40,7 +49,7 @@ A prompt sequence is a simple JSON file with the following structure: "lifespan": "timespan", "duration": "1h", "constraints": { - "path-pattern": "/home/foo/example.txt", + "path-pattern": "$BASE_PATH/example.txt", "permissions": [ "read", "write" ] } } @@ -50,9 +59,36 @@ A prompt sequence is a simple JSON file with the following structure: } ``` +## Variables + +Bash style `$VAR_NAME` and `${VAR_NAME}` variables are supported within prompt sequence JSON +files, being replaced using the `--var "VAR_NAME:value"` command line flag as shown in the +example above. This is handled as a simple string replacement before attempting to parse the +file, so long as the resulting string parses as valid JSON prompt sequence you are free to +make use of variable substitution as you see fit. + +In the event that a scripted client run fails, it will output the fully resolved script to +standard out as part of its exit behaviour so you are able to inspect what the final run +looked like. + + +## Fields + ### Version The only supported version at this time is `1`. This field is required. +### Prompt Filter +The top-level `prompt-filter` field is used to provide a filter that will be used +to determine which prompts the client will attempt to match against as part of the +provided `prompts` sequence. It is not required that you specify a top level filter, +but if you do then all prompts not matching it will be ignored by the scripted client. + +> This is particularly useful for factoring out common elements such the name of the +> snap being matched against and based paths for home prompts etc. + +See the `Prompt filter fields` section below for specific details on each of the +supported fields for a filter. + ### Prompts A sequence of prompt cases: a prompt filter allowing for structural matching against the next prompt in the sequence along with a template for build the diff --git a/prompting-client/resources/prompt-sequence-tests/e2e_erroring_write_test.json b/prompting-client/resources/prompt-sequence-tests/e2e_erroring_write_test.json index 60bec03..a9b13a3 100644 --- a/prompting-client/resources/prompt-sequence-tests/e2e_erroring_write_test.json +++ b/prompting-client/resources/prompt-sequence-tests/e2e_erroring_write_test.json @@ -6,7 +6,7 @@ "snap": "aa-prompting-test", "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/test-1.txt", + "path": "$BASE_PATH/test-1.txt", "requested-permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } diff --git a/prompting-client/resources/prompt-sequence-tests/e2e_unexpected_additional_prompt_test.json b/prompting-client/resources/prompt-sequence-tests/e2e_unexpected_additional_prompt_test.json index 1a52bb2..fe8ec19 100644 --- a/prompting-client/resources/prompt-sequence-tests/e2e_unexpected_additional_prompt_test.json +++ b/prompting-client/resources/prompt-sequence-tests/e2e_unexpected_additional_prompt_test.json @@ -1,12 +1,19 @@ { "version": 1, + "prompt-filter": { + "snap": "aa-prompting-test", + "interface": "home", + "constraints": { + "path": "$BASE_PATH/*" + } + }, "prompts": [ { "prompt-filter": { "snap": "aa-prompting-test", "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/test-1.txt", + "path": ".*/test-1.txt", "requested-permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } @@ -24,7 +31,7 @@ "snap": "aa-prompting-test", "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/test-2.txt", + "path": ".*/test-2.txt", "requested-permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } diff --git a/prompting-client/resources/prompt-sequence-tests/e2e_write_test.json b/prompting-client/resources/prompt-sequence-tests/e2e_write_test.json index ff3228d..93ba394 100644 --- a/prompting-client/resources/prompt-sequence-tests/e2e_write_test.json +++ b/prompting-client/resources/prompt-sequence-tests/e2e_write_test.json @@ -1,12 +1,17 @@ { "version": 1, + "prompt-filter": { + "snap": "aa-prompting-test", + "interface": "home", + "constraints": { + "path": "$BASE_PATH/*" + } + }, "prompts": [ { "prompt-filter": { - "snap": "aa-prompting-test", - "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/test-1.txt", + "path": ".*/test-1.txt", "requested-permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } @@ -21,10 +26,8 @@ }, { "prompt-filter": { - "snap": "aa-prompting-test", - "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/test-2.txt", + "path": ".*/test-2.txt", "requested-permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } @@ -33,16 +36,15 @@ "action": "allow", "lifespan": "forever", "constraints": { + "path": "${PROMPT_PATH}/../**", "permissions": [ "read", "write" ] } } }, { "prompt-filter": { - "snap": "aa-prompting-test", - "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/test-3.txt", + "path": ".*/test-3.txt", "requested-permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } diff --git a/prompting-client/resources/prompt-sequence-tests/e2e_wrong_path_test.json b/prompting-client/resources/prompt-sequence-tests/e2e_wrong_path_test.json index 3a01954..9bc3aec 100644 --- a/prompting-client/resources/prompt-sequence-tests/e2e_wrong_path_test.json +++ b/prompting-client/resources/prompt-sequence-tests/e2e_wrong_path_test.json @@ -6,7 +6,7 @@ "snap": "aa-prompting-test", "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/test-1.txt", + "path": "$BASE_PATH/test-1.txt", "requested-permissions": [ "write" ], "available-permissions": [ "read", "write", "execute" ] } @@ -24,7 +24,7 @@ "snap": "aa-prompting-test", "interface": "home", "constraints": { - "path": "/home/[a-zA-Z0-9]+/test/.+/incorrect.txt", + "path": "$BASE_PATH/incorrect.txt", "requested-permissions": [ "read" ], "available-permissions": [ "read", "write", "execute" ] } diff --git a/prompting-client/resources/prompt-sequence-tests/sequence_with_top_level_filter_and_vars.json b/prompting-client/resources/prompt-sequence-tests/sequence_with_top_level_filter_and_vars.json new file mode 100644 index 0000000..f0a10f3 --- /dev/null +++ b/prompting-client/resources/prompt-sequence-tests/sequence_with_top_level_filter_and_vars.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "prompt-filter": { + "snap": "aa-prompting-test", + "interface": "home", + "constraints": { + "path": "$BASE_PATH/*" + } + }, + "prompts": [ + { + "prompt-filter": { + "snap": "testSnap", + "interface": "home", + "constraints": { + "path": "$BASE_PATH/bar", + "requested-permissions": [ "read" ], + "available-permissions": [ "read", "write", "execute" ] + } + }, + "reply": { + "action": "allow", + "lifespan": "single", + "constraints": { + "path-pattern": "${BASE_PATH}/bar", + "permissions": [ "read", "write" ] + } + } + } + ] +} diff --git a/prompting-client/resources/scripted-tests/happy-path-read/prompt-sequence.json b/prompting-client/resources/scripted-tests/happy-path-read/prompt-sequence.json new file mode 100644 index 0000000..d1a62cd --- /dev/null +++ b/prompting-client/resources/scripted-tests/happy-path-read/prompt-sequence.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "prompt-filter": { + "snap": "aa-prompting-test", + "interface": "home", + "constraints": { + "path": "$BASE_PATH/.*" + } + }, + "prompts": [ + { + "prompt-filter": { + "constraints": { + "path": ".*/test.txt", + "requested-permissions": [ "read" ] + } + }, + "reply": { + "action": "allow", + "lifespan": "single", + "constraints": { + "path-pattern": "${BASE_PATH}/test.txt", + "permissions": [ "read" ] + } + } + } + ] +} diff --git a/prompting-client/resources/scripted-tests/happy-path-read/test.sh b/prompting-client/resources/scripted-tests/happy-path-read/test.sh new file mode 100755 index 0000000..219f3ab --- /dev/null +++ b/prompting-client/resources/scripted-tests/happy-path-read/test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +# A simple allow once test of a read prompt. +# Running this test script requires that the accompanying prompt-sequence.json +# file be present in the output directory. + +PREFIX="$1" +TEST_DIR="/home/ubuntu/test/$PREFIX" + +prompting-client.scripted \ + --script="$TEST_DIR/prompt-sequence.json" \ + --var "BASE_PATH:$TEST_DIR" | tee "$TEST_DIR/outfile" & + +# Ensure that the test client is already listening +sleep 0.2 +TEST_OUTPUT="$(aa-prompting-test.read "$PREFIX")" + +# Ensure that the test client has time to write its output +# For tests with a grace period this will need to be taken into account as well +sleep 0.2 +CLIENT_OUTPUT="$(cat "$TEST_DIR/outfile")" + +if [ "$CLIENT_OUTPUT" != "success" ]; then + echo "test failed" + echo "output='$CLIENT_OUTPUT'" + exit 1 +fi + +if [ "$TEST_OUTPUT" != "testing testing 1 2 3" ]; then + echo "test script failed" + exit 1 +fi diff --git a/prompting-client/src/bin/scripted.rs b/prompting-client/src/bin/scripted.rs index 3f318a7..0bc9b76 100644 --- a/prompting-client/src/bin/scripted.rs +++ b/prompting-client/src/bin/scripted.rs @@ -1,13 +1,17 @@ //! A simple command line prompting client use clap::Parser; use prompting_client::{ - cli_actions::run_scripted_client_loop, snapd_client::SnapdSocketClient, Result, + cli_actions::ScriptedClient, snapd_client::SnapdSocketClient, Error, Result, }; -use std::io::stderr; +use std::{io::stderr, process::exit}; use tracing::subscriber::set_global_default; use tracing_subscriber::FmtSubscriber; -/// Run a scripted client expecting a given sequence of prompts +/// Run a scripted client expecting a given sequence of prompts. +/// +/// If the provided prompt sequence completes cleanly the client will print "success" on standard +/// out. If there are any errors then the first error will be printed as "error: $errorMessage" on +/// standard out and the client will exit with a non-zero exit code. #[derive(Debug, Parser)] #[clap(about, long_about = None)] struct Args { @@ -18,6 +22,9 @@ struct Args { #[clap(short, long, value_name = "FILE")] script: String, + #[clap(long, action = clap::ArgAction::Append)] + var: Vec, + /// The number of seconds to wait following completion of the script to check for any /// unexpected additional prompts. #[clap(short, long, value_name = "SECONDS")] @@ -29,6 +36,7 @@ async fn main() -> Result<()> { let Args { verbose, script, + var, grace_period, } = Args::parse(); @@ -44,5 +52,53 @@ async fn main() -> Result<()> { let mut c = SnapdSocketClient::default(); c.exit_if_prompting_not_enabled().await?; - run_scripted_client_loop(&mut c, script, grace_period).await + let vars = parse_vars(&var)?; + + eprintln!("creating client"); + let mut scripted_client = ScriptedClient::try_new(script, &vars, c.clone())?; + match scripted_client.run(&mut c, grace_period).await { + Ok(_) => println!("success"), + Err(e) => { + println!("{e}\n\nscript: {}", scripted_client.raw_seq()); + exit(1); + } + } + + Ok(()) +} + +fn parse_vars(raw: &[String]) -> Result> { + let mut vars: Vec<(&str, &str)> = Vec::with_capacity(raw.len()); + + for s in raw.iter() { + match s.split_once(':') { + Some((k, v)) => vars.push((k.trim(), v.trim())), + None => return Err(Error::InvalidScriptVariable { raw: s.clone() }), + } + } + + Ok(vars) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_vars_works() { + let raw_vars = vec![ + "foo:bar".into(), + "a: b".to_string(), + "x :y".to_string(), + "1 : 2".to_string(), + ]; + + match parse_vars(&raw_vars) { + Err(e) => panic!("ERROR: {e}"), + Ok(vars) => assert_eq!( + vars, + vec![("foo", "bar"), ("a", "b"), ("x", "y"), ("1", "2")] + ), + } + } } diff --git a/prompting-client/src/cli_actions/mod.rs b/prompting-client/src/cli_actions/mod.rs index c39a6a8..97d2f1b 100644 --- a/prompting-client/src/cli_actions/mod.rs +++ b/prompting-client/src/cli_actions/mod.rs @@ -4,4 +4,4 @@ mod scripted; pub use echo_loop::run_echo_loop; pub use log_level::set_logging_filter; -pub use scripted::run_scripted_client_loop; +pub use scripted::ScriptedClient; diff --git a/prompting-client/src/cli_actions/scripted.rs b/prompting-client/src/cli_actions/scripted.rs index 6605002..6ef1985 100644 --- a/prompting-client/src/cli_actions/scripted.rs +++ b/prompting-client/src/cli_actions/scripted.rs @@ -15,54 +15,6 @@ use std::time::Duration; use tokio::{select, sync::mpsc::unbounded_channel}; use tracing::{debug, error, info, warn}; -/// Run a scripted client that actions prompts based on a predefined sequence of prompts that we -/// expect to see. -pub async fn run_scripted_client_loop( - snapd_client: &mut SnapdSocketClient, - path: String, - grace_period: Option, -) -> Result<()> { - let mut scripted_client = - ScriptedClient::try_new_allowing_script_read(path, snapd_client.clone())?; - let (tx_prompts, mut rx_prompts) = unbounded_channel(); - - info!("starting poll loop"); - let mut poll_loop = PollLoop::new(snapd_client.clone(), tx_prompts); - poll_loop.skip_outstanding_prompts(); - tokio::spawn(async move { poll_loop.run().await }); - - info!( - script=%scripted_client.path, - n_prompts=%scripted_client.seq.len(), - "running provided script" - ); - - while scripted_client.is_running() { - match rx_prompts.recv().await { - Some(PromptUpdate::Add(ep)) => { - scripted_client - .reply_retrying_errors(ep, snapd_client) - .await? - } - - Some(PromptUpdate::Drop(PromptId(id))) => warn!(%id, "drop for prompt id"), - - None => break, - } - } - - let grace_period = match grace_period { - Some(n) => n, - None => return Ok(()), - }; - - info!(seconds=%grace_period, "sequence complete, entering grace period"); - select! { - _ = tokio::time::sleep(Duration::from_secs(grace_period)) => Ok(()), - res = grace_period_deny_and_error(snapd_client) => res, - } -} - /// Poll for outstanding prompts and auto-deny them before returning an error. This function will /// loop until at least one un-actioned prompt is encountered. async fn grace_period_deny_and_error(snapd_client: &mut SnapdSocketClient) -> Result<()> { @@ -95,14 +47,16 @@ async fn grace_period_deny_and_error(snapd_client: &mut SnapdSocketClient) -> Re } #[derive(Debug)] -struct ScriptedClient { +pub struct ScriptedClient { seq: PromptSequence, + raw_seq: String, path: String, } impl ScriptedClient { - fn try_new_allowing_script_read( + pub fn try_new( path: String, + vars: &[(&str, &str)], mut snapd_client: SnapdSocketClient, ) -> Result { // We need to spawn a task to wait for the read prompt we generate when reading in our @@ -111,13 +65,15 @@ impl ScriptedClient { let mut filter = PromptFilter::default(); let mut constraints = HomeConstraintsFilter::default(); constraints - .try_with_path(format!(".*/{path}")) + .try_with_path(format!(".*{path}")) .expect("valid regex"); filter .with_snap(SNAP_NAME) .with_interface("home") .with_constraints(constraints); + eprintln!("script path: {path}"); + tokio::task::spawn(async move { loop { let pending = snapd_client.pending_prompt_ids().await.unwrap(); @@ -138,9 +94,56 @@ impl ScriptedClient { } }); - let seq = PromptSequence::try_new_from_file(&path)?; + let (seq, raw_seq) = PromptSequence::try_new_from_file(&path, vars)?; + + Ok(Self { seq, raw_seq, path }) + } + + pub fn raw_seq(&self) -> &str { + &self.raw_seq + } + + /// Run a scripted client that actions prompts based on a predefined sequence of prompts that we + /// expect to see. + pub async fn run( + &mut self, + snapd_client: &mut SnapdSocketClient, + grace_period: Option, + ) -> Result<()> { + let (tx_prompts, mut rx_prompts) = unbounded_channel(); + + info!("starting poll loop"); + let mut poll_loop = PollLoop::new(snapd_client.clone(), tx_prompts); + poll_loop.skip_outstanding_prompts(); + tokio::spawn(async move { poll_loop.run().await }); + + info!(script=%self.path, n_prompts=%self.seq.len(), "running provided script"); + + while self.is_running() { + match rx_prompts.recv().await { + Some(PromptUpdate::Add(ep)) if self.should_handle(&ep) => { + self.reply(ep, snapd_client).await? + } + Some(PromptUpdate::Add(ep)) => eprintln!("dropping prompt: {ep:?}"), + Some(PromptUpdate::Drop(PromptId(id))) => warn!(%id, "drop for prompt id"), + None => break, + } + } + + let grace_period = match grace_period { + Some(n) => n, + None => return Ok(()), + }; + + info!(seconds=%grace_period, "sequence complete, entering grace period"); + select! { + _ = tokio::time::sleep(Duration::from_secs(grace_period)) => Ok(()), + res = grace_period_deny_and_error(snapd_client) => res, + } + } - Ok(Self { seq, path }) + fn should_handle(&self, ep: &EnrichedPrompt) -> bool { + self.seq.should_handle(&ep.prompt) } async fn reply_for_prompt( @@ -173,7 +176,7 @@ impl ScriptedClient { !self.seq.is_empty() } - async fn reply_retrying_errors( + async fn reply( &mut self, EnrichedPrompt { prompt, .. }: EnrichedPrompt, snapd_client: &mut SnapdSocketClient, diff --git a/prompting-client/src/lib.rs b/prompting-client/src/lib.rs index f642b53..bc4e0a8 100644 --- a/prompting-client/src/lib.rs +++ b/prompting-client/src/lib.rs @@ -59,6 +59,9 @@ pub enum Error { #[error("{version} is not supported recording version.")] InvalidRecordingVersion { version: u8 }, + #[error("invalid script variable. Expected 'key:value' but saw {raw:?}")] + InvalidScriptVariable { raw: String }, + #[error("{uri} is not valid: {reason}")] InvalidUri { reason: &'static str, uri: String }, diff --git a/prompting-client/src/prompt_sequence.rs b/prompting-client/src/prompt_sequence.rs index 1a8a38f..af24c14 100644 --- a/prompting-client/src/prompt_sequence.rs +++ b/prompting-client/src/prompt_sequence.rs @@ -12,20 +12,32 @@ use std::{collections::VecDeque, fs}; #[serde(rename_all = "kebab-case")] pub struct PromptSequence { version: u8, + filter: Option, prompts: VecDeque, #[serde(skip, default)] index: usize, } impl PromptSequence { - pub fn try_new_from_file(path: &str) -> crate::Result { - Self::try_new_from_string(&fs::read_to_string(path)?) + pub fn try_new_from_file(path: &str, vars: &[(&str, &str)]) -> crate::Result<(Self, String)> { + Self::try_new_from_string(fs::read_to_string(path)?, vars) } - pub fn try_new_from_string(content: &str) -> crate::Result { - let seq = serde_json::from_str(content)?; + pub fn try_new_from_string( + content: impl Into, + vars: &[(&str, &str)], + ) -> crate::Result<(Self, String)> { + let content = apply_vars(content.into(), vars); + let seq = serde_json::from_str(&content)?; - Ok(seq) + Ok((seq, content)) + } + + pub fn should_handle(&self, p: &TypedPrompt) -> bool { + match &self.filter { + Some(f) => f.matches(p), + None => true, + } } pub fn try_match_next(&mut self, p: TypedPrompt) -> Result { @@ -54,12 +66,35 @@ impl PromptSequence { } } +fn apply_vars(mut content: String, vars: &[(&str, &str)]) -> String { + for (k, v) in vars { + content = content.replace(&format!("${k}"), v); + content = content.replace(&format!("${{{k}}}"), v); + } + + content +} + #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] enum TypedPromptCase { Home(PromptCase), } +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum TypedPromptFilter { + Home(PromptFilter), +} + +impl TypedPromptFilter { + pub fn matches(&self, prompt: &TypedPrompt) -> bool { + match (self, prompt) { + (Self::Home(f), TypedPrompt::Home(p)) => f.matches(p).is_success(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct PromptCase @@ -319,10 +354,24 @@ mod tests { assert_eq!(filter.matches(&p), expected); } + #[test] + fn apply_vars_works() { + let raw = include_str!( + "../resources/prompt-sequence-tests/sequence_with_top_level_filter_and_vars.json" + ); + assert!(raw.contains("$BASE_PATH")); + assert!(raw.contains("${BASE_PATH}")); + + let s = apply_vars(raw.to_string(), &[("BASE_PATH", "/home/foo")]); + + assert!(!s.contains("$BASE_PATH")); + assert!(!s.contains("${BASE_PATH}")); + } + #[dir_cases("resources/prompt-sequence-tests")] #[test] fn deserialize_prompt_sequence_works(path: &str, data: &str) { - let res = PromptSequence::try_new_from_string(data); + let res = PromptSequence::try_new_from_string(data, &[("BASE_PATH", "/home/foo")]); assert!(res.is_ok(), "error parsing {path}: {:?}", res); } diff --git a/prompting-client/tests/integration.rs b/prompting-client/tests/integration.rs index 8415e9d..0c45157 100644 --- a/prompting-client/tests/integration.rs +++ b/prompting-client/tests/integration.rs @@ -7,7 +7,7 @@ //! Creation of the SnapdSocketClient needs to be handled before spawning the test snap so that //! polling `after` is correct to pick up the prompt. use prompting_client::{ - cli_actions::run_scripted_client_loop, + cli_actions::ScriptedClient, prompt_sequence::MatchError, snapd_client::{ interfaces::{home::HomeInterface, SnapInterface}, @@ -20,6 +20,7 @@ use simple_test_case::test_case; use std::{ env, fs, io::{self, ErrorKind}, + os::unix::fs::PermissionsExt, sync::mpsc::{channel, Receiver}, time::Duration, }; @@ -421,7 +422,10 @@ async fn scripted_client_works_with_simple_matching() -> Result<()> { let (prefix, dir_path) = setup_test_dir(None, &[("seq.json", seq)])?; let _rx = spawn_for_output("aa-prompting-test.create", vec![prefix]); - let res = run_scripted_client_loop(&mut c, format!("{dir_path}/seq.json"), None).await; + + let mut scripted_client = + ScriptedClient::try_new(format!("{dir_path}/seq.json"), &[], c.clone())?; + let res = scripted_client.run(&mut c, None).await; sleep(Duration::from_millis(50)).await; if let Err(e) = res { @@ -450,9 +454,14 @@ async fn invalid_prompt_sequence_reply_errors() -> Result<()> { let (prefix, dir_path) = setup_test_dir(None, &[("seq.json", seq)])?; spawn_for_output("aa-prompting-test.create", vec![prefix]); - let res = run_scripted_client_loop(&mut c, format!("{dir_path}/seq.json"), None).await; - match res { + let mut scripted_client = ScriptedClient::try_new( + format!("{dir_path}/seq.json"), + &[("BASE_PATH", &dir_path)], + c.clone(), + )?; + + match scripted_client.run(&mut c, None).await { Err(Error::FailedPromptSequence { error: MatchError::UnexpectedError { error }, }) => { @@ -478,9 +487,13 @@ async fn unexpected_prompt_in_sequence_errors() -> Result<()> { let (prefix, dir_path) = setup_test_dir(None, &[("seq.json", seq)])?; spawn_for_output("aa-prompting-test.create", vec![prefix]); - let res = run_scripted_client_loop(&mut c, format!("{dir_path}/seq.json"), None).await; + let mut scripted_client = ScriptedClient::try_new( + format!("{dir_path}/seq.json"), + &[("BASE_PATH", &dir_path)], + c.clone(), + )?; - match res { + match scripted_client.run(&mut c, None).await { Err(Error::FailedPromptSequence { error: MatchError::MatchFailures { index, failures }, }) => { @@ -505,9 +518,13 @@ async fn prompt_after_a_sequence_with_grace_period_errors() -> Result<()> { let (prefix, dir_path) = setup_test_dir(None, &[("seq.json", seq)])?; let _rx = spawn_for_output("aa-prompting-test.create", vec![prefix]); - let res = run_scripted_client_loop(&mut c, format!("{dir_path}/seq.json"), Some(5)).await; + let mut scripted_client = ScriptedClient::try_new( + format!("{dir_path}/seq.json"), + &[("BASE_PATH", &dir_path)], + c.clone(), + )?; - match res { + match scripted_client.run(&mut c, Some(5)).await { Err(Error::FailedPromptSequence { error: MatchError::UnexpectedPrompts { .. }, }) => Ok(()), @@ -526,7 +543,13 @@ async fn prompt_after_a_sequence_without_grace_period_is_ok() -> Result<()> { let (prefix, dir_path) = setup_test_dir(None, &[("seq.json", seq)])?; let _rx = spawn_for_output("aa-prompting-test.create", vec![prefix]); - let res = run_scripted_client_loop(&mut c, format!("{dir_path}/seq.json"), None).await; + let mut scripted_client = ScriptedClient::try_new( + format!("{dir_path}/seq.json"), + &[("BASE_PATH", &dir_path)], + c.clone(), + )?; + + let res = scripted_client.run(&mut c, None).await; // Sleep to create a gap between this test and the next so that the outstanding prompts do not // get picked up as part of that test. Without this we are racey around what the first prompt @@ -538,3 +561,38 @@ async fn prompt_after_a_sequence_without_grace_period_is_ok() -> Result<()> { Err(e) => panic!("unexpected error: {e}"), } } + +#[tokio::test] +#[serial] +async fn scripted_client_test_allow() -> Result<()> { + let script = include_str!("../resources/scripted-tests/happy-path-read/test.sh"); + let seq = include_str!("../resources/scripted-tests/happy-path-read/prompt-sequence.json"); + + let (prefix, dir_path) = setup_test_dir( + None, + &[ + ("test.txt", "testing testing 1 2 3"), + ("test.sh", script), + ("prompt-sequence.json", seq), + ], + )?; + + let script_path = format!("{dir_path}/test.sh"); + let file = fs::File::open(&script_path)?; + let mut perms = file.metadata()?.permissions(); + perms.set_mode(perms.mode() | 0o111); // Set executable bit for all users (chmod +x) + file.set_permissions(perms)?; + + let res = Command::new(script_path) + .args([prefix]) + .spawn() + .expect("script to start") + .wait() + .await; + + if let Err(e) = res { + panic!("test failed: {e}"); + } + + Ok(()) +}