use std::collections::BTreeMap;
use serde_json::json;
use super::{
ArgumentValue, ArgvSlot, CommandParameterContract, DeclaredCommandContract, EnvBindingContract,
FillPiece, FillTemplate, RenderError,
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn hole(parameter: &str) -> FillTemplate {
FillTemplate {
pieces: vec![FillPiece::Hole {
parameter: parameter.to_owned(),
}],
}
}
fn mixed(prefix: &str, parameter: &str, suffix: &str) -> FillTemplate {
FillTemplate {
pieces: vec![
FillPiece::Literal {
text: prefix.to_owned(),
},
FillPiece::Hole {
parameter: parameter.to_owned(),
},
FillPiece::Literal {
text: suffix.to_owned(),
},
],
}
}
fn slot(fill: FillTemplate, label: &str, admits_leading_dash: bool) -> ArgvSlot {
ArgvSlot {
fill,
label: label.to_owned(),
admits_leading_dash,
}
}
fn parameter(name: &str, list: bool, default: Option<FillTemplate>) -> CommandParameterContract {
CommandParameterContract {
name: name.to_owned(),
list,
default,
}
}
fn contract(
parameters: Vec<CommandParameterContract>,
program: &[&str],
args: Vec<ArgvSlot>,
) -> DeclaredCommandContract {
DeclaredCommandContract {
name: "probe".to_owned(),
parameters,
program: program.iter().map(|word| (*word).to_owned()).collect(),
args,
env: Vec::new(),
cwd: None,
hardened_path: None,
timeout_ms: None,
timeout_owner: None,
}
}
fn supplied(pairs: &[(&str, ArgumentValue)]) -> BTreeMap<String, ArgumentValue> {
pairs
.iter()
.map(|(name, value)| ((*name).to_owned(), value.clone()))
.collect()
}
#[test]
fn a_value_carrying_shell_metacharacters_arrives_as_one_argv_element() -> TestResult {
let command = contract(
vec![parameter("value", false, None)],
&["echo"],
vec![slot(hole("value"), "value", true)],
);
let rendered = command.render(&supplied(&[(
"value",
ArgumentValue::scalar("$(boom); rm -rf /"),
)]))?;
assert_eq!(
rendered.argv,
vec!["echo".to_owned(), "$(boom); rm -rf /".to_owned()],
"a hostile value must be one inert element, never re-split"
);
Ok(())
}
#[test]
fn a_list_alone_in_a_slot_becomes_one_element_per_item() -> TestResult {
let command = contract(
vec![parameter("paths", true, None)],
&["git", "add"],
vec![slot(hole("paths"), "paths", true)],
);
let rendered = command.render(&supplied(&[(
"paths",
ArgumentValue::list(["a.rs", "b rs", "c.rs"]),
)]))?;
assert_eq!(
rendered.argv,
vec!["git", "add", "a.rs", "b rs", "c.rs"]
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>()
);
Ok(())
}
#[test]
fn a_default_resolves_against_the_parameters_declared_before_it() -> TestResult {
let command = contract(
vec![
parameter("base", false, None),
parameter("range", false, Some(mixed("", "base", "..HEAD"))),
],
&["git", "log"],
vec![slot(hole("range"), "range", true)],
);
let rendered = command.render(&supplied(&[("base", ArgumentValue::scalar("main"))]))?;
assert_eq!(rendered.argv, vec!["git", "log", "main..HEAD"]);
Ok(())
}
#[test]
fn a_supplied_value_beats_the_declared_default() -> TestResult {
let command = contract(
vec![parameter(
"who",
false,
Some(FillTemplate::literal("nobody")),
)],
&["echo"],
vec![slot(hole("who"), "who", true)],
);
let rendered = command.render(&supplied(&[("who", ArgumentValue::scalar("world"))]))?;
assert_eq!(rendered.argv, vec!["echo", "world"]);
Ok(())
}
#[test]
fn a_parameter_with_no_value_and_no_default_refuses_by_name() {
let command = contract(
vec![parameter("who", false, None)],
&["echo"],
vec![slot(hole("who"), "who", true)],
);
assert_eq!(
command.render(&BTreeMap::new()),
Err(RenderError::ArgumentMissing {
command: "probe".to_owned(),
parameter: "who".to_owned(),
})
);
}
#[test]
fn a_value_the_command_does_not_declare_refuses_by_name() {
let command = contract(Vec::new(), &["echo"], Vec::new());
assert_eq!(
command.render(&supplied(&[("stray", ArgumentValue::scalar("x"))])),
Err(RenderError::ArgumentUndeclared {
command: "probe".to_owned(),
parameter: "stray".to_owned(),
})
);
}
#[test]
fn a_shape_mismatch_refuses_naming_both_shapes() {
let command = contract(
vec![parameter("paths", true, None)],
&["git"],
vec![slot(hole("paths"), "paths", true)],
);
assert_eq!(
command.render(&supplied(&[("paths", ArgumentValue::scalar("a.rs"))])),
Err(RenderError::ArgumentTypeMismatch {
command: "probe".to_owned(),
parameter: "paths".to_owned(),
observed: "a.rs".to_owned(),
declared: "list",
supplied: "single value",
})
);
}
#[test]
fn a_leading_dash_operand_refuses_where_the_program_still_reads_options() {
let command = contract(
vec![parameter("name", false, None)],
&["git", "tag"],
vec![slot(hole("name"), "name", false)],
);
let Err(error) = command.render(&supplied(&[("name", ArgumentValue::scalar("-n"))])) else {
panic_free_failure("a leading-dash operand must refuse");
return;
};
assert_eq!(
error,
RenderError::LeadingDashOperand {
command: "probe".to_owned(),
argument: "name".to_owned(),
element: "-n".to_owned(),
marker: "--",
}
);
}
#[test]
fn a_named_arguments_value_is_not_dash_guarded() -> TestResult {
let command = contract(
vec![parameter("count", false, None)],
&["grep"],
vec![
slot(FillTemplate::literal("--max-count"), "--max-count", true),
slot(hole("count"), "--max-count", true),
],
);
let rendered = command.render(&supplied(&[("count", ArgumentValue::scalar("-3"))]))?;
assert_eq!(rendered.argv, vec!["grep", "--max-count", "-3"]);
Ok(())
}
#[test]
fn the_same_bytes_pass_once_the_end_of_options_marker_stands_before_them() -> TestResult {
let command = contract(
vec![parameter("name", false, None)],
&["git", "tag"],
vec![
slot(FillTemplate::literal("--"), "--", true),
slot(hole("name"), "name", true),
],
);
let rendered = command.render(&supplied(&[("name", ArgumentValue::scalar("-n"))]))?;
assert_eq!(rendered.argv, vec!["git", "tag", "--", "-n"]);
Ok(())
}
#[test]
fn env_and_path_bindings_render_from_the_same_bound_values() -> TestResult {
let mut command = contract(vec![parameter("root", false, None)], &["true"], Vec::new());
command.env = vec![EnvBindingContract {
name: "PROJECT_ROOT".to_owned(),
value: hole("root"),
}];
command.hardened_path = Some(mixed("", "root", "/bin"));
let rendered = command.render(&supplied(&[("root", ArgumentValue::scalar("/srv/app"))]))?;
assert_eq!(
rendered.env,
vec![("PROJECT_ROOT".to_owned(), "/srv/app".to_owned())]
);
assert_eq!(rendered.hardened_path, Some("/srv/app/bin".to_owned()));
Ok(())
}
#[test]
fn json_values_take_their_one_obvious_argument_form() -> TestResult {
assert_eq!(
ArgumentValue::from_json("p", &json!("text"))?,
ArgumentValue::scalar("text")
);
assert_eq!(
ArgumentValue::from_json("p", &json!(7))?,
ArgumentValue::scalar("7")
);
assert_eq!(
ArgumentValue::from_json("p", &json!(true))?,
ArgumentValue::scalar("true")
);
assert_eq!(
ArgumentValue::from_json("p", &json!(["a", 2]))?,
ArgumentValue::list(["a", "2"])
);
Ok(())
}
#[test]
fn json_values_with_no_argument_form_refuse_by_name() {
for (value, kind) in [
(json!(null), "null"),
(json!({ "a": 1 }), "an object"),
(json!([[1]]), "a nested array"),
] {
assert_eq!(
ArgumentValue::from_json("p", &value),
Err(RenderError::UnrepresentableValue {
parameter: "p".to_owned(),
kind,
})
);
}
assert_eq!(
ArgumentValue::from_json("p", &json!("has\0nul")),
Err(RenderError::InteriorNul {
parameter: "p".to_owned(),
})
);
}
#[test]
fn the_contract_round_trips_through_json() -> TestResult {
let mut command = contract(
vec![parameter(
"who",
false,
Some(FillTemplate::literal("world")),
)],
&["echo"],
vec![slot(mixed("hello ", "who", "!"), "greeting", false)],
);
command.cwd = Some("{workspace_root}".to_owned());
command.timeout_ms = Some(30_000);
command.timeout_owner = Some("release".to_owned());
let encoded = serde_json::to_string(&command)?;
let decoded: DeclaredCommandContract = serde_json::from_str(&encoded)?;
assert_eq!(decoded, command);
Ok(())
}
#[test]
fn every_distinguishing_edit_moves_the_identity_bytes() -> TestResult {
use crate::contract::{ActionBodyContract, ActionContract, CommandBodyCapture};
let base = contract(
vec![parameter("who", false, None)],
&["echo"],
vec![slot(hole("who"), "who", true)],
);
let action = |capture: CommandBodyCapture, command: DeclaredCommandContract| ActionContract {
name: "greet".to_owned(),
input_schema: serde_json::json!({}),
output_schema: serde_json::json!({}),
node: None,
timeout: None,
retry: None,
advisory: false,
agent: false,
body: Some(ActionBodyContract::Command {
capture,
command: Box::new(command),
}),
};
let mut seen = std::collections::BTreeSet::new();
let mut record = |contract: &ActionContract| -> Result<(), Box<dyn std::error::Error>> {
let mut bytes = Vec::new();
crate::declared_command::encode_identity(
&mut bytes,
match contract.body.as_ref() {
Some(ActionBodyContract::Command { command, .. }) => command.as_ref(),
_ => return Err("the fixture carries a command body".into()),
},
);
if let Some(ActionBodyContract::Command { capture, .. }) = contract.body.as_ref() {
bytes.push(u8::from(matches!(capture, CommandBodyCapture::Json)));
}
assert!(seen.insert(bytes), "two distinct commands hashed alike");
Ok(())
};
record(&action(CommandBodyCapture::Text, base.clone()))?;
record(&action(CommandBodyCapture::Json, base.clone()))?;
let mut edited = base.clone();
edited.program = vec!["printf".to_owned()];
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited
.args
.push(slot(FillTemplate::literal("--"), "--", true));
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited
.args
.insert(0, slot(FillTemplate::literal("--"), "--", true));
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited.args[0].admits_leading_dash = false;
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited.env = vec![EnvBindingContract {
name: "A".to_owned(),
value: FillTemplate::literal("1"),
}];
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited.cwd = Some("/srv".to_owned());
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited.hardened_path = Some(FillTemplate::literal("/usr/bin"));
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited.timeout_ms = Some(1_000);
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited.timeout_owner = Some("ops".to_owned());
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base.clone();
edited.parameters[0].list = true;
record(&action(CommandBodyCapture::Text, edited))?;
let mut edited = base;
edited.parameters[0].default = Some(FillTemplate::literal("world"));
record(&action(CommandBodyCapture::Text, edited))?;
Ok(())
}
fn panic_free_failure(reason: &str) {
assert!(reason.is_empty(), "{reason}");
}