From a720867c4c1aea9288d420d3165ddca3f795a953 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Sun, 8 Mar 2026 20:00:46 +0100 Subject: [PATCH] initial commit --- .DS_Store | Bin 0 -> 8196 bytes AGENTS.md | 21 + CLAUDE.md | 1 + COMMANDS.md | 13 + README.md | 79 +++ SHELL_FEATURES.md | 93 ++++ interp/allowed_paths.go | 163 ++++++ interp/allowed_paths_internal_test.go | 150 ++++++ interp/allowed_paths_test.go | 218 ++++++++ interp/api.go | 415 +++++++++++++++ interp/builtins/break_continue.go | 51 ++ interp/builtins/builtins.go | 78 +++ interp/builtins/cat.go | 46 ++ interp/builtins/echo.go | 17 + interp/builtins/exit.go | 38 ++ interp/builtins/true_false.go | 14 + interp/handler.go | 104 ++++ interp/handler_exec.go | 103 ++++ interp/runner.go | 488 ++++++++++++++++++ interp/validate.go | 196 +++++++ interp/vars.go | 162 ++++++ .../scenarios/cmd/cat/basic/concat_order.yaml | 26 + .../cmd/cat/basic/dash_no_stdin.yaml | 8 + .../cmd/cat/basic/dash_with_heredoc.yaml | 11 + tests/scenarios/cmd/cat/basic/empty_file.yaml | 16 + tests/scenarios/cmd/cat/basic/file.yaml | 22 + tests/scenarios/cmd/cat/basic/heredoc.yaml | 11 + .../cmd/cat/basic/heredoc_multiline.yaml | 15 + .../cmd/cat/basic/multiple_files.yaml | 19 + tests/scenarios/cmd/cat/basic/no_stdin.yaml | 8 + tests/scenarios/cmd/cat/basic/pipe_chain.yaml | 9 + .../scenarios/cmd/cat/basic/piped_stdin.yaml | 9 + .../scenarios/cmd/cat/basic/single_file.yaml | 16 + .../cmd/cat/basic/special_chars_in_file.yaml | 14 + .../cmd/cat/errors/is_directory.yaml | 18 + .../cmd/cat/errors/is_directory_windows.yaml | 18 + .../cmd/cat/errors/multiple_all_fail.yaml | 14 + .../cat/errors/multiple_all_fail_windows.yaml | 14 + .../cmd/cat/errors/multiple_first_fails.yaml | 20 + .../errors/multiple_first_fails_windows.yaml | 20 + .../cmd/cat/errors/multiple_second_fails.yaml | 20 + .../errors/multiple_second_fails_windows.yaml | 20 + .../cmd/cat/errors/nonexistent_continues.yaml | 15 + .../errors/nonexistent_continues_windows.yaml | 15 + .../cmd/cat/errors/nonexistent_file.yaml | 13 + .../cat/errors/nonexistent_file_windows.yaml | 13 + .../cmd/cat/errors/redirect_nonexistent.yaml | 13 + .../errors/redirect_nonexistent_windows.yaml | 13 + .../cmd/echo/basic/empty_string.yaml | 9 + tests/scenarios/cmd/echo/basic/exit_code.yaml | 9 + .../cmd/echo/basic/multiple_args.yaml | 9 + .../cmd/echo/basic/multiple_calls.yaml | 13 + .../cmd/echo/basic/multiple_hyphens.yaml | 11 + .../echo/basic/newline_in_double_quotes.yaml | 11 + tests/scenarios/cmd/echo/basic/no_args.yaml | 9 + .../cmd/echo/basic/numeric_args.yaml | 9 + tests/scenarios/cmd/echo/basic/simple.yaml | 9 + .../cmd/echo/basic/single_hyphen.yaml | 9 + .../echo/basic/single_hyphen_with_text.yaml | 9 + .../scenarios/cmd/echo/basic/tab_in_arg.yaml | 8 + .../cmd/echo/basic/whitespace_in_quotes.yaml | 9 + .../literal/backslash_not_interpreted.yaml | 9 + .../cmd/echo/literal/dash_E_is_literal.yaml | 11 + .../cmd/echo/literal/dash_n_is_literal.yaml | 10 + .../echo/literal/double_quote_escapes.yaml | 10 + .../cmd/echo/literal/mixed_flags_literal.yaml | 13 + .../cmd/echo/literal/special_chars.yaml | 10 + .../cmd/echo/shell_features/in_for_loop.yaml | 13 + .../cmd/echo/shell_features/pipe.yaml | 9 + .../shell_features/variable_expansion.yaml | 10 + .../cmd/exit/basic/default_after_true.yaml | 9 + .../cmd/exit/basic/double_dash_separator.yaml | 8 + tests/scenarios/cmd/exit/basic/no_args.yaml | 8 + .../cmd/exit/basic/no_args_last_code.yaml | 9 + .../exit/basic/overrides_previous_status.yaml | 9 + .../cmd/exit/basic/preserves_all_output.yaml | 14 + .../cmd/exit/basic/preserves_stdout.yaml | 13 + .../cmd/exit/basic/stops_execution.yaml | 11 + tests/scenarios/cmd/exit/basic/zero.yaml | 8 + tests/scenarios/cmd/exit/codes/1.yaml | 8 + tests/scenarios/cmd/exit/codes/126.yaml | 8 + tests/scenarios/cmd/exit/codes/127.yaml | 8 + tests/scenarios/cmd/exit/codes/17.yaml | 8 + tests/scenarios/cmd/exit/codes/2.yaml | 8 + tests/scenarios/cmd/exit/codes/255.yaml | 8 + tests/scenarios/cmd/exit/codes/256_wraps.yaml | 8 + tests/scenarios/cmd/exit/codes/42.yaml | 8 + .../scenarios/cmd/exit/codes/large_wraps.yaml | 8 + tests/scenarios/cmd/exit/codes/negative.yaml | 8 + tests/scenarios/cmd/exit/errors/float.yaml | 10 + .../cmd/exit/errors/invalid_continues.yaml | 10 + .../cmd/exit/errors/invalid_string.yaml | 10 + .../cmd/exit/errors/multiple_args.yaml | 10 + .../exit/errors/multiple_args_continues.yaml | 10 + .../shell_features/after_failed_command.yaml | 11 + .../exit/shell_features/after_pipeline.yaml | 11 + .../cmd/exit/shell_features/in_and_chain.yaml | 9 + .../exit/shell_features/in_brace_group.yaml | 11 + .../cmd/exit/shell_features/in_for_loop.yaml | 13 + .../cmd/exit/shell_features/in_or_chain.yaml | 9 + .../shell_features/in_semicolon_chain.yaml | 9 + .../exit/shell_features/with_variable.yaml | 9 + .../scenarios/cmd/false/basic/after_true.yaml | 11 + tests/scenarios/cmd/false/basic/multiple.yaml | 12 + .../scenarios/cmd/false/basic/no_stdout.yaml | 10 + .../scenarios/cmd/false/basic/standalone.yaml | 8 + .../cmd/false/basic/with_arguments.yaml | 8 + .../cmd/false/control_flow/and_chain.yaml | 8 + .../false/control_flow/in_brace_group.yaml | 10 + .../cmd/false/control_flow/in_for_loop.yaml | 13 + .../cmd/false/control_flow/negation.yaml | 10 + .../false/control_flow/or_and_recovery.yaml | 9 + .../cmd/false/control_flow/pipe_left.yaml | 9 + .../cmd/false/control_flow/pipe_right.yaml | 8 + .../false/control_flow/semicolon_chain.yaml | 9 + .../exit_status/exit_status_captured.yaml | 10 + .../exit_status/exit_status_in_chain.yaml | 9 + .../scenarios/cmd/true/basic/after_false.yaml | 11 + tests/scenarios/cmd/true/basic/multiple.yaml | 12 + tests/scenarios/cmd/true/basic/no_stdout.yaml | 10 + .../scenarios/cmd/true/basic/standalone.yaml | 8 + .../cmd/true/basic/with_arguments.yaml | 8 + .../cmd/true/control_flow/and_chain.yaml | 9 + .../true/control_flow/and_or_recovery.yaml | 9 + .../cmd/true/control_flow/in_brace_group.yaml | 10 + .../cmd/true/control_flow/in_for_loop.yaml | 13 + .../control_flow/loop_preserves_zero.yaml | 14 + .../cmd/true/control_flow/negation.yaml | 10 + .../cmd/true/control_flow/or_skips.yaml | 8 + .../cmd/true/control_flow/pipe_left.yaml | 9 + .../cmd/true/control_flow/pipe_right.yaml | 8 + .../true/control_flow/semicolon_chain.yaml | 9 + .../exit_status/exit_status_captured.yaml | 10 + .../exit_status/exit_status_in_chain.yaml | 9 + .../cmd/true/exit_status/resets_failure.yaml | 13 + .../cmd/unknown_cmd/basic/after_echo.yaml | 10 + .../cmd/unknown_cmd/basic/before_echo.yaml | 10 + .../basic/multiple_consecutive.yaml | 10 + .../cmd/unknown_cmd/basic/multiword_name.yaml | 8 + .../cmd/unknown_cmd/basic/simple.yaml | 8 + .../unknown_cmd/basic/underscore_name.yaml | 8 + .../cmd/unknown_cmd/common_progs/bash.yaml | 10 + .../cmd/unknown_cmd/common_progs/chmod.yaml | 10 + .../cmd/unknown_cmd/common_progs/cp.yaml | 10 + .../cmd/unknown_cmd/common_progs/curl.yaml | 10 + .../cmd/unknown_cmd/common_progs/grep.yaml | 10 + .../cmd/unknown_cmd/common_progs/ls.yaml | 10 + .../cmd/unknown_cmd/common_progs/mv.yaml | 10 + .../cmd/unknown_cmd/common_progs/python.yaml | 10 + .../cmd/unknown_cmd/common_progs/rm.yaml | 10 + .../cmd/unknown_cmd/common_progs/sed.yaml | 10 + .../cmd/unknown_cmd/common_progs/sh.yaml | 10 + .../cmd/unknown_cmd/common_progs/wget.yaml | 10 + .../exit_code/and_operator_skips.yaml | 8 + .../unknown_cmd/exit_code/and_or_chain.yaml | 9 + .../unknown_cmd/exit_code/exit_code_127.yaml | 8 + .../exit_code/exit_code_captured.yaml | 9 + .../cmd/unknown_cmd/exit_code/or_chain.yaml | 9 + .../exit_code/or_operator_continues.yaml | 9 + .../exit_code/semicolon_continues.yaml | 9 + .../cmd/unknown_cmd/with_args/args.yaml | 8 + .../cmd/unknown_cmd/with_args/flags.yaml | 8 + .../unknown_cmd/with_args/quoted_args.yaml | 8 + .../unknown_cmd/with_args/variable_arg.yaml | 9 + .../cat_after_blocked_continues.yaml | 19 + .../allowed_paths/cat_inside_allowed.yaml | 14 + .../allowed_paths/cat_outside_allowed.yaml | 16 + .../allowed_paths/default_blocks_all.yaml | 13 + .../allowed_paths/dir_itself_allowed.yaml | 17 + .../dotdot_filename_allowed.yaml | 14 + .../dotdot_filename_in_subdir.yaml | 14 + .../empty_allowed_blocks_all.yaml | 14 + .../allowed_paths/for_loop_cat_files.yaml | 18 + .../allowed_paths/glob_inside_allowed.yaml | 16 + .../glob_no_match_in_allowed.yaml | 14 + .../allowed_paths/glob_outside_allowed.yaml | 18 + .../allowed_paths/heredoc_unaffected.yaml | 11 + .../allowed_paths/multiple_allowed_dirs.yaml | 20 + .../multiple_allowed_one_blocked.yaml | 20 + .../allowed_paths/multiple_cat_same_dir.yaml | 20 + .../nonexistent_file_in_allowed.yaml | 16 + .../nonexistent_file_in_allowed_windows.yaml | 16 + .../allowed_paths/pipe_inside_allowed.yaml | 14 + .../redirect_from_one_cat_from_other.yaml | 18 + .../redirect_inside_allowed.yaml | 14 + .../redirect_outside_allowed.yaml | 16 + .../redirect_variable_inside.yaml | 15 + .../redirect_variable_outside.yaml | 18 + .../allowed_paths/subdir_of_allowed.yaml | 14 + .../allowed_paths/symlink_chain_escape.yaml | 18 + .../allowed_paths/symlink_escape_to_dir.yaml | 17 + .../symlink_escape_to_dir_windows.yaml | 17 + .../allowed_paths/symlink_escape_to_file.yaml | 16 + .../symlink_redirect_escape.yaml | 16 + .../allowed_paths/symlink_within_allowed.yaml | 16 + .../allowed_paths/traversal_blocked.yaml | 14 + .../allowed_paths/traversal_to_sibling.yaml | 17 + .../allowed_paths/variable_path_inside.yaml | 16 + .../allowed_paths/variable_path_outside.yaml | 19 + .../shell/allowed_redirects/heredoc.yaml | 11 + .../shell/allowed_redirects/input.yaml | 13 + .../allowed_redirects/input_empty_file.yaml | 13 + .../allowed_redirects/input_in_brace.yaml | 13 + .../allowed_redirects/input_in_for_loop.yaml | 15 + .../allowed_redirects/input_multiline.yaml | 13 + .../input_overrides_pipe.yaml | 13 + .../input_pipe_combination.yaml | 13 + .../input_quoted_filename.yaml | 13 + .../allowed_redirects/input_sequential.yaml | 16 + .../input_special_chars.yaml | 13 + .../input_variable_filename.yaml | 14 + .../input_with_logic_ops.yaml | 13 + .../blocked_commands/arithmetic_cmd.yaml | 10 + .../shell/blocked_commands/background.yaml | 10 + .../blocked_commands/blocked_after_valid.yaml | 12 + .../shell/blocked_commands/c_style_for.yaml | 10 + .../blocked_commands/case_statement.yaml | 10 + .../shell/blocked_commands/coproc.yaml | 10 + .../shell/blocked_commands/declare.yaml | 10 + .../shell/blocked_commands/export.yaml | 10 + .../shell/blocked_commands/extglob.yaml | 10 + .../shell/blocked_commands/function_decl.yaml | 10 + .../shell/blocked_commands/if_else.yaml | 10 + .../shell/blocked_commands/if_statement.yaml | 10 + .../scenarios/shell/blocked_commands/let.yaml | 10 + .../shell/blocked_commands/local.yaml | 10 + .../shell/blocked_commands/pipe_all.yaml | 10 + .../shell/blocked_commands/process_sub.yaml | 10 + .../shell/blocked_commands/readonly.yaml | 10 + .../blocked_commands/select_statement.yaml | 10 + .../shell/blocked_commands/subshell.yaml | 10 + .../shell/blocked_commands/test_clause.yaml | 10 + .../shell/blocked_commands/time.yaml | 10 + .../shell/blocked_commands/until_loop.yaml | 10 + .../shell/blocked_commands/while_loop.yaml | 10 + .../shell/blocked_redirects/append_all.yaml | 10 + .../blocked_after_valid.yaml | 12 + .../shell/blocked_redirects/dup_in.yaml | 10 + .../shell/blocked_redirects/dup_out.yaml | 10 + .../shell/blocked_redirects/herestring.yaml | 10 + .../shell/blocked_redirects/read_write.yaml | 10 + .../shell/blocked_redirects/stderr_write.yaml | 10 + .../shell/blocked_redirects/write_all.yaml | 10 + .../shell/blocked_redirects/write_append.yaml | 10 + .../blocked_redirects/write_clobber.yaml | 10 + .../blocked_redirects/write_truncate.yaml | 10 + .../shell/brace_group/as_and_operand.yaml | 9 + .../shell/brace_group/as_or_operand.yaml | 9 + tests/scenarios/shell/brace_group/basic.yaml | 10 + .../shell/brace_group/chained_with_and.yaml | 10 + .../shell/brace_group/chained_with_or.yaml | 9 + .../shell/brace_group/deeply_nested.yaml | 11 + .../shell/brace_group/effect_with_exit.yaml | 11 + .../shell/brace_group/exit_code.yaml | 8 + .../shell/brace_group/exit_code_tracking.yaml | 13 + .../shell/brace_group/exit_inside.yaml | 10 + .../brace_group/multiple_sequential.yaml | 11 + .../multiple_sequential_newlines.yaml | 19 + tests/scenarios/shell/brace_group/nested.yaml | 10 + .../shell/brace_group/nested_var_scope.yaml | 20 + .../scenarios/shell/brace_group/newlines.yaml | 13 + .../shell/brace_group/only_false.yaml | 8 + .../shell/brace_group/only_true.yaml | 8 + .../shell/brace_group/single_command.yaml | 9 + .../shell/brace_group/var_shared_scope.yaml | 12 + .../shell/brace_group/with_and_or.yaml | 10 + .../shell/brace_group/with_for_loop.yaml | 15 + .../brace_group/with_logic_exit_code.yaml | 13 + .../shell/brace_group/with_pipe.yaml | 10 + .../cmd_separator/basic/empty_lines.yaml | 15 + .../basic/long_semicolon_chain.yaml | 18 + .../shell/cmd_separator/basic/mixed.yaml | 13 + .../cmd_separator/basic/newline_multiple.yaml | 15 + .../cmd_separator/basic/newline_simple.yaml | 11 + .../cmd_separator/basic/only_empty_lines.yaml | 9 + .../basic/semicolon_multiple.yaml | 12 + .../cmd_separator/basic/semicolon_simple.yaml | 10 + .../basic/trailing_semicolon.yaml | 9 + .../basic/whitespace_around.yaml | 11 + .../control_flow/after_for_loop.yaml | 11 + .../control_flow/and_or_with_semicolons.yaml | 11 + .../control_flow/braces_and_for_mixed.yaml | 16 + .../control_flow/braces_then_command.yaml | 10 + .../control_flow/for_loop_body.yaml | 12 + .../control_flow/for_loop_newline.yaml | 14 + .../cmd_separator/control_flow/in_braces.yaml | 11 + .../control_flow/multiple_for_semicolons.yaml | 12 + .../cmd_separator/control_flow/with_exit.yaml | 9 + .../control_flow/with_exit_nonzero.yaml | 9 + .../exit_code/continues_after_failure.yaml | 10 + .../exit_code/exit_code_custom.yaml | 9 + .../exit_code/exit_code_first_fails.yaml | 9 + .../exit_code/exit_code_last.yaml | 9 + .../exit_code/exit_code_last_of_many.yaml | 8 + .../exit_code/exit_code_middle_fails.yaml | 10 + .../exit_code/exit_code_second_fails.yaml | 9 + .../exit_code/exit_status_chain.yaml | 11 + .../exit_code/newline_after_failure.yaml | 12 + .../exit_code/unknown_command_continues.yaml | 9 + .../var_sharing/exit_status_variable.yaml | 10 + .../var_sharing/var_from_brace_group.yaml | 9 + .../var_sharing/var_from_for_loop.yaml | 9 + .../var_sharing/variable_accumulate.yaml | 10 + .../var_sharing/variable_newline.yaml | 10 + .../var_sharing/variable_override.yaml | 10 + .../var_sharing/variable_set_then_use.yaml | 9 + .../with_ops/and_fails_then_semicolon.yaml | 9 + .../with_ops/and_then_semicolon.yaml | 11 + .../with_ops/complex_all_features.yaml | 16 + .../cmd_separator/with_ops/complex_chain.yaml | 12 + .../with_ops/multiple_lists_newlines.yaml | 16 + .../with_ops/or_then_semicolon.yaml | 9 + .../with_ops/pipe_then_semicolon.yaml | 10 + .../cmd_separator/with_ops/with_and.yaml | 11 + .../shell/cmd_separator/with_ops/with_or.yaml | 10 + .../cmd_separator/with_ops/with_pipe.yaml | 11 + .../shell/comments/after_assignment.yaml | 10 + .../shell/comments/after_semicolon.yaml | 11 + .../shell/comments/backslash_ending.yaml | 10 + .../comments/backslash_ending_with_text.yaml | 10 + tests/scenarios/shell/comments/basic.yaml | 13 + .../comments/comment_truncates_args.yaml | 11 + .../shell/comments/escaped_hash_literal.yaml | 9 + .../shell/comments/hash_in_quoted_string.yaml | 11 + .../shell/comments/hash_in_word.yaml | 10 + tests/scenarios/shell/comments/in_and_or.yaml | 12 + .../shell/comments/in_brace_group.yaml | 11 + .../scenarios/shell/comments/in_for_loop.yaml | 15 + .../comments/in_for_loop_all_positions.yaml | 15 + .../scenarios/shell/comments/in_pipeline.yaml | 10 + .../shell/comments/indented_comments.yaml | 13 + tests/scenarios/shell/comments/inline.yaml | 9 + .../shell/comments/multiple_consecutive.yaml | 14 + .../shell/comments/multiple_hashes.yaml | 12 + .../shell/comments/only_comments.yaml | 17 + .../comments/standalone_between_commands.yaml | 13 + tests/scenarios/shell/empty_script/empty.yaml | 7 + .../shell/environment/builtin_ifs_set.yaml | 11 + .../shell/environment/empty_by_default.yaml | 10 + .../shell/environment/empty_var_vs_unset.yaml | 10 + .../environment/env_option_empty_value.yaml | 10 + .../env_option_field_splitting.yaml | 10 + .../environment/env_option_no_extra_vars.yaml | 11 + .../environment/env_option_override.yaml | 12 + .../env_option_path_like_value.yaml | 10 + .../environment/env_option_special_chars.yaml | 10 + .../env_option_vars_accessible.yaml | 11 + .../shell/environment/home_not_set.yaml | 10 + .../shell/environment/ifs_default_tab.yaml | 13 + .../shell/environment/ifs_empty_no_split.yaml | 13 + .../ifs_multiple_custom_chars.yaml | 9 + .../shell/environment/ifs_tab_only.yaml | 6 + .../shell/environment/inline_assignment.yaml | 10 + .../inline_assignment_temporary.yaml | 9 + .../shell/environment/lang_not_set.yaml | 8 + .../environment/multiple_assignments.yaml | 10 + .../environment/no_parent_propagation.yaml | 15 + .../shell/environment/optind_set_to_one.yaml | 7 + .../shell/environment/override_provided.yaml | 15 + .../shell/environment/path_not_set.yaml | 8 + .../environment/provided_vars_accessible.yaml | 15 + .../shell/environment/pwd_is_set.yaml | 9 + .../shell/environment/pwd_is_set_windows.yaml | 9 + .../shell/environment/shell_not_set.yaml | 8 + .../shell/environment/term_not_set.yaml | 8 + .../shell/environment/tilde_not_expanded.yaml | 10 + .../environment/tilde_path_not_expanded.yaml | 10 + .../shell/environment/user_not_set.yaml | 8 + .../shell/environment/variable_overwrite.yaml | 10 + .../environment/variable_with_spaces.yaml | 8 + .../double_quotes_prevent_globbing.yaml | 14 + .../double_quotes_prevent_splitting.yaml | 10 + .../field_splitting/echo_args_custom_ifs.yaml | 9 + .../empty_ifs_no_split_any.yaml | 9 + .../empty_quoted_preserved.yaml | 10 + .../empty_unquoted_removed.yaml | 11 + .../ifs_change_affects_later.yaml | 10 + .../field_splitting/ifs_colon_separator.yaml | 13 + .../field_splitting/ifs_equals_sign.yaml | 9 + .../shell/field_splitting/ifs_pipe_char.yaml | 9 + .../ifs_whitespace_coalescing.yaml | 11 + .../leading_nonws_ifs_empty_field.yaml | 9 + .../leading_trailing_ws_trimmed.yaml | 8 + .../field_splitting/literal_not_split.yaml | 8 + .../mixed_tabs_spaces_coalesce.yaml | 6 + .../field_splitting/mixed_ws_nonws_ifs.yaml | 9 + .../multiple_consecutive_nonws_ifs.yaml | 9 + .../newline_splitting_default_ifs.yaml | 6 + .../nonws_ifs_empty_fields.yaml | 9 + .../quoted_empty_preserved_multiple.yaml | 9 + .../single_quoted_no_expand.yaml | 8 + .../field_splitting/unquoted_var_splits.yaml | 12 + .../field_splitting/var_in_for_items.yaml | 12 + .../for_clause/basic/and_or_in_body.yaml | 17 + .../shell/for_clause/basic/brace_in_body.yaml | 14 + .../for_clause/basic/commands_after.yaml | 12 + .../for_clause/basic/commands_before.yaml | 12 + .../shell/for_clause/basic/do_as_word.yaml | 9 + .../for_clause/basic/do_done_as_words.yaml | 10 + .../shell/for_clause/basic/empty_body.yaml | 12 + .../shell/for_clause/basic/empty_list.yaml | 10 + .../shell/for_clause/basic/exit_in_body.yaml | 14 + .../basic/exit_nonzero_in_body.yaml | 12 + .../for_clause/basic/for_as_varname.yaml | 10 + .../shell/for_clause/basic/in_as_varname.yaml | 9 + .../shell/for_clause/basic/in_as_word.yaml | 9 + .../shell/for_clause/basic/iterate_words.yaml | 11 + .../for_clause/basic/loop_var_overwrite.yaml | 18 + .../shell/for_clause/basic/many_items.yaml | 13 + .../for_clause/basic/multiline_body.yaml | 15 + .../basic/multiple_loops_sequence.yaml | 15 + .../for_clause/basic/negation_in_body.yaml | 13 + .../basic/newline_separated_do.yaml | 13 + .../shell/for_clause/basic/pipe_in_body.yaml | 12 + .../basic/reserved_words_as_items.yaml | 12 + .../shell/for_clause/basic/semicolon_do.yaml | 10 + .../shell/for_clause/basic/single_item.yaml | 9 + .../for_clause/basic/single_line_compact.yaml | 14 + .../basic/special_characters_in_items.yaml | 10 + .../basic/tab_separated_tokens.yaml | 9 + .../basic/words_not_assignments.yaml | 10 + .../break_cont/break_after_and.yaml | 13 + .../for_clause/break_cont/break_after_or.yaml | 13 + .../break_cont/break_before_and.yaml | 8 + .../break_cont/break_before_or.yaml | 8 + .../break_default_in_triple_nested.yaml | 27 + .../break_cont/break_exceeds_depth.yaml | 14 + .../break_cont/break_from_brace.yaml | 13 + .../break_inner_continues_outer.yaml | 27 + .../break_cont/break_invalid_arg.yaml | 11 + .../for_clause/break_cont/break_mid_body.yaml | 13 + .../break_cont/break_much_exceeds_depth.yaml | 11 + .../break_cont/break_multiple_args.yaml | 12 + .../break_cont/break_outside_loop.yaml | 12 + .../for_clause/break_cont/break_simple.yaml | 14 + .../break_three_nested_outermost.yaml | 25 + .../break_two_in_triple_nested.yaml | 34 ++ .../break_cont/break_two_nested.yaml | 26 + .../break_cont/break_two_outermost.yaml | 20 + .../for_clause/break_cont/break_with_arg.yaml | 14 + .../break_cont/break_with_negation.yaml | 11 + .../for_clause/break_cont/break_zero_arg.yaml | 13 + .../break_cont/continue_after_and.yaml | 13 + .../break_cont/continue_after_or.yaml | 13 + .../break_cont/continue_before_and.yaml | 8 + .../break_cont/continue_before_or.yaml | 8 + .../continue_default_in_triple_nested.yaml | 28 + .../break_cont/continue_default_operand.yaml | 24 + .../break_cont/continue_exceeds_depth.yaml | 8 + .../break_cont/continue_from_brace.yaml | 15 + .../break_cont/continue_inner_in_nested.yaml | 33 ++ .../break_cont/continue_invalid_arg.yaml | 11 + .../continue_much_exceeds_depth.yaml | 11 + .../break_cont/continue_multiple_args.yaml | 12 + .../break_cont/continue_outside_loop.yaml | 12 + .../break_cont/continue_simple.yaml | 15 + .../break_cont/continue_three_outermost.yaml | 31 ++ .../continue_two_in_triple_nested.yaml | 46 ++ .../break_cont/continue_two_nested.yaml | 28 + .../break_cont/continue_two_outermost.yaml | 24 + .../break_cont/continue_with_arg.yaml | 13 + .../break_cont/continue_with_negation.yaml | 14 + .../break_cont/continue_zero_arg.yaml | 13 + .../break_preserves_zero_after_false.yaml | 10 + .../continue_preserves_zero_after_false.yaml | 10 + .../exit_code/exit_code_after_break.yaml | 10 + .../exit_code/exit_code_body_mixed.yaml | 14 + .../exit_code/exit_code_empty_list.yaml | 8 + .../exit_code/exit_code_last_cmd.yaml | 8 + .../exit_code/exit_code_last_iteration.yaml | 15 + .../exit_code_preserves_previous.yaml | 11 + .../exit_code/exit_code_success.yaml | 10 + .../exit_code/unknown_cmd_in_body.yaml | 8 + .../shell/for_clause/nested/basic.yaml | 16 + .../shell/for_clause/nested/break_inner.yaml | 15 + .../shell/for_clause/nested/break_outer.yaml | 16 + .../for_clause/nested/break_three_levels.yaml | 20 + .../for_clause/nested/continue_inner.yaml | 16 + .../for_clause/nested/continue_outer.yaml | 18 + .../nested/continue_three_levels.yaml | 21 + .../shell/for_clause/nested/with_pipe.yaml | 16 + .../var_scoping/env_var_in_items.yaml | 14 + .../var_scoping/loop_var_reused.yaml | 18 + .../var_scoping/nested_inner_var_visible.yaml | 14 + .../unset_var_empty_expansion.yaml | 12 + .../var_scoping/var_assigned_in_body.yaml | 12 + .../var_scoping/var_expansion_in_items.yaml | 12 + .../var_scoping/var_from_outer_scope.yaml | 10 + .../var_scoping/var_no_clobber_outer.yaml | 13 + .../var_scoping/var_persists_after_loop.yaml | 13 + .../globbing/bracket/character_range.yaml | 22 + .../shell/globbing/bracket/character_set.yaml | 18 + .../shell/globbing/bracket/digit_range.yaml | 22 + .../globbing/bracket/multiple_brackets.yaml | 20 + .../shell/globbing/bracket/negation.yaml | 18 + .../globbing/bracket/negation_range.yaml | 22 + .../globbing/bracket/no_match_literal.yaml | 10 + .../bracket/no_match_literal_range.yaml | 14 + .../for_loop/iterate_bracket_glob.yaml | 21 + .../shell/globbing/for_loop/iterate_glob.yaml | 20 + .../question_mark/does_not_match_empty.yaml | 18 + .../question_mark/mixed_with_star.yaml | 18 + .../multiple_question_marks.yaml | 20 + .../question_mark/no_match_literal.yaml | 10 + .../question_mark/question_then_star.yaml | 20 + .../question_mark/single_char_match.yaml | 18 + .../question_mark/star_then_question.yaml | 18 + .../question_mark/three_question_marks.yaml | 18 + .../quoting/double_quoted_var_no_glob.yaml | 17 + .../quoting/double_quotes_no_expand.yaml | 13 + .../quoting/single_quotes_no_expand.yaml | 13 + .../quoting/unquoted_var_glob_expands.yaml | 19 + .../shell/globbing/star/all_files.yaml | 17 + .../shell/globbing/star/basic_match.yaml | 18 + .../star/double_star_same_as_single.yaml | 16 + .../shell/globbing/star/in_subdirectory.yaml | 17 + .../star/in_subdirectory_windows.yaml | 17 + .../globbing/star/matches_directories.yaml | 15 + .../globbing/star/matches_empty_prefix.yaml | 16 + .../globbing/star/multiple_patterns.yaml | 20 + .../shell/globbing/star/no_match_literal.yaml | 10 + .../globbing/star/prefix_and_suffix.yaml | 20 + .../shell/globbing/star/prefix_match.yaml | 18 + .../shell/globbing/star/skips_dotfiles.yaml | 16 + .../shell/globbing/star/star_alone.yaml | 18 + .../shell/globbing/star/suffix_match.yaml | 18 + tests/scenarios/shell/heredoc/and_logic.yaml | 12 + .../shell/heredoc/backslash_handling.yaml | 15 + .../heredoc/backslash_quoted_delimiter.yaml | 12 + tests/scenarios/shell/heredoc/basic.yaml | 13 + .../shell/heredoc/custom_delimiter.yaml | 11 + .../heredoc/delimiter_not_substring.yaml | 13 + .../heredoc/delimiter_starting_with_dash.yaml | 11 + .../shell/heredoc/empty_content.yaml | 9 + .../shell/heredoc/in_brace_group.yaml | 13 + .../scenarios/shell/heredoc/in_for_loop.yaml | 14 + .../shell/heredoc/line_continuation.yaml | 11 + .../shell/heredoc/mixed_content.yaml | 20 + tests/scenarios/shell/heredoc/multiline.yaml | 15 + .../shell/heredoc/multiple_sequential.yaml | 15 + .../shell/heredoc/multivar_expansion.yaml | 15 + .../shell/heredoc/numeric_delimiter.yaml | 11 + tests/scenarios/shell/heredoc/or_logic.yaml | 11 + .../heredoc/partially_quoted_double.yaml | 12 + .../heredoc/partially_quoted_single.yaml | 12 + tests/scenarios/shell/heredoc/pipe.yaml | 11 + tests/scenarios/shell/heredoc/pipe_chain.yaml | 11 + .../heredoc/preserves_trailing_spaces.yaml | 7 + .../quoted_delimiter_no_expansion.yaml | 15 + .../heredoc/quoted_no_backslash_interp.yaml | 19 + .../single_double_quotes_in_content.yaml | 13 + .../scenarios/shell/heredoc/single_line.yaml | 11 + .../shell/heredoc/special_chars.yaml | 17 + .../shell/heredoc/tabs_preserved.yaml | 7 + .../shell/heredoc/var_expansion.yaml | 12 + .../heredoc_dash/backslash_handling.yaml | 10 + .../shell/heredoc_dash/backslash_quoted.yaml | 8 + tests/scenarios/shell/heredoc_dash/basic.yaml | 9 + .../shell/heredoc_dash/blank_lines.yaml | 7 + tests/scenarios/shell/heredoc_dash/empty.yaml | 7 + .../shell/heredoc_dash/in_brace.yaml | 8 + .../shell/heredoc_dash/in_for_loop.yaml | 9 + .../heredoc_dash/indented_delimiter.yaml | 9 + .../shell/heredoc_dash/mixed_indent.yaml | 7 + .../shell/heredoc_dash/multiline.yaml | 10 + .../shell/heredoc_dash/multiple_tabs.yaml | 8 + .../shell/heredoc_dash/partially_quoted.yaml | 8 + tests/scenarios/shell/heredoc_dash/pipe.yaml | 8 + .../shell/heredoc_dash/quoted_delimiter.yaml | 10 + .../heredoc_dash/spaces_not_stripped.yaml | 7 + .../shell/heredoc_dash/var_expansion.yaml | 8 + .../shell/heredoc_dash/with_logic_ops.yaml | 9 + tests/scenarios/shell/inline_var/basic.yaml | 11 + .../inline_args_expanded_first.yaml | 10 + .../shell/inline_var/inline_empty_value.yaml | 12 + .../inline_var/inline_multiple_commands.yaml | 13 + .../inline_no_effect_on_others.yaml | 13 + .../shell/inline_var/inline_on_false.yaml | 11 + .../shell/inline_var/inline_on_true.yaml | 11 + .../inline_var/inline_restore_unset.yaml | 11 + .../inline_same_var_sequential.yaml | 16 + .../shell/inline_var/inline_unset_after.yaml | 11 + .../inline_var/inline_value_from_var.yaml | 12 + .../shell/inline_var/multiple_inline.yaml | 11 + .../persistent_on_empty_expansion.yaml | 12 + .../shell/inline_var/restore_after.yaml | 12 + .../across_and_operator.yaml | 10 + .../line_continuation/across_or_operator.yaml | 10 + .../shell/line_continuation/across_pipe.yaml | 10 + .../shell/line_continuation/basic.yaml | 10 + .../empty_continuation_line.yaml | 11 + .../line_continuation/in_and_operator.yaml | 14 + .../line_continuation/in_assignment.yaml | 11 + .../in_assignment_then_echo.yaml | 10 + .../in_assignment_value.yaml | 14 + .../in_assignment_value_simple.yaml | 11 + .../line_continuation/in_brace_keywords.yaml | 14 + .../line_continuation/in_command_name.yaml | 10 + .../shell/line_continuation/in_echo_args.yaml | 11 + .../line_continuation/in_for_item_list.yaml | 17 + .../line_continuation/in_for_keywords.yaml | 17 + .../line_continuation/in_or_operator.yaml | 12 + .../line_continuation/in_pipe_operator.yaml | 10 + .../line_continuation/in_variable_name.yaml | 11 + .../inside_double_quotes.yaml | 11 + .../multiple_consecutive.yaml | 11 + .../and_basic_behavior/both_succeed.yaml | 10 + .../and_basic_behavior/chain_all_succeed.yaml | 11 + .../chain_fails_at_fourth.yaml | 11 + .../chain_five_succeed.yaml | 13 + .../chain_middle_fails.yaml | 9 + .../first_fails_skips_rest.yaml | 8 + .../and_basic_behavior/left_fails.yaml | 8 + .../and_basic_behavior/right_fails.yaml | 9 + .../and_basic_behavior/with_assignment.yaml | 9 + .../and_basic_behavior/with_true.yaml | 9 + .../exit_code/and_custom_exit_codes.yaml | 8 + .../exit_code/and_exit_code_from_right.yaml | 8 + .../and_preserves_left_exit_code.yaml | 8 + .../exit_code/last_executed_pipeline.yaml | 10 + .../exit_code/mixed_exit_code_recovery.yaml | 8 + .../exit_code/or_both_custom_exit.yaml | 8 + .../exit_code/or_exit_code_from_right.yaml | 8 + .../exit_code/or_exit_code_left_success.yaml | 8 + .../shell/logic_ops/mixed/all_fail_chain.yaml | 8 + .../logic_ops/mixed/and_or_and_chain.yaml | 10 + .../logic_ops/mixed/and_then_or_failure.yaml | 9 + .../logic_ops/mixed/and_then_or_success.yaml | 10 + .../mixed/brace_left_operand_fails.yaml | 10 + .../mixed/long_alternating_chain.yaml | 10 + .../mixed/multiple_independent_lists.yaml | 15 + .../logic_ops/mixed/or_and_or_chain.yaml | 9 + .../logic_ops/mixed/or_then_and_failure.yaml | 10 + .../logic_ops/mixed/or_then_and_success.yaml | 10 + .../logic_ops/mixed/pipelines_in_list.yaml | 9 + .../logic_ops/mixed/recovery_with_braces.yaml | 10 + .../mixed/semicolons_separate_lists.yaml | 10 + .../logic_ops/mixed/three_cmd_fallback.yaml | 11 + .../or_basic_behavior/both_fail.yaml | 8 + .../or_basic_behavior/chain_all_fail.yaml | 8 + .../chain_first_succeeds.yaml | 9 + .../chain_five_all_fail.yaml | 8 + .../chain_last_succeeds.yaml | 9 + .../chain_middle_succeeds.yaml | 9 + .../or_basic_behavior/left_fails.yaml | 9 + .../or_basic_behavior/left_succeeds.yaml | 9 + .../or_basic_behavior/with_assignment.yaml | 10 + .../or_basic_behavior/with_true.yaml | 8 + .../output/and_no_output_on_skip.yaml | 9 + .../output/and_output_accumulates.yaml | 11 + .../logic_ops/output/linebreak_after_and.yaml | 14 + .../logic_ops/output/linebreak_after_or.yaml | 12 + .../output/mixed_output_partial.yaml | 11 + .../and_exit_status_variable.yaml | 9 + .../var_interact/chain_var_propagation.yaml | 9 + .../exit_status_through_chain.yaml | 13 + .../var_interact/or_assignment_persists.yaml | 10 + .../var_interact/or_exit_status_variable.yaml | 9 + .../or_var_visible_on_fallback.yaml | 9 + .../negation/basic/exit_status_captured.yaml | 15 + .../basic/multiple_negations_sequence.yaml | 16 + .../shell/negation/basic/negate_echo.yaml | 9 + .../shell/negation/basic/negate_false.yaml | 8 + .../shell/negation/basic/negate_true.yaml | 8 + .../negation/basic/negate_unknown_cmd.yaml | 9 + .../negation/compound/negate_brace_group.yaml | 8 + .../compound/negate_brace_multi_cmd.yaml | 17 + .../compound/negate_brace_with_false.yaml | 9 + .../compound/negate_for_empty_list.yaml | 8 + .../compound/negate_for_false_body.yaml | 8 + .../negation/compound/negate_for_loop.yaml | 8 + .../compound/negate_nested_brace.yaml | 8 + .../negation/exit_code/negate_exit_one.yaml | 8 + .../negation/exit_code/negate_exit_zero.yaml | 8 + .../multiple_negations_in_and_chain.yaml | 9 + .../with_logic_ops/negate_and_chain.yaml | 9 + .../with_logic_ops/negate_false_then_and.yaml | 9 + .../with_logic_ops/negate_or_chain.yaml | 8 + .../with_logic_ops/negate_true_then_or.yaml | 9 + .../with_pipe/negate_pipe_failure.yaml | 8 + .../with_pipe/negate_pipe_success.yaml | 9 + .../with_pipe/negate_three_stage_pipe.yaml | 9 + .../pipe/basic/blank_lines_after_pipe_op.yaml | 11 + .../shell/pipe/basic/brace_both_sides.yaml | 10 + .../shell/pipe/basic/brace_group_in_pipe.yaml | 10 + .../scenarios/shell/pipe/basic/cat_file.yaml | 17 + tests/scenarios/shell/pipe/basic/chain.yaml | 9 + .../shell/pipe/basic/echo_ignores_stdin.yaml | 9 + .../shell/pipe/basic/empty_echo.yaml | 9 + .../shell/pipe/basic/five_stage.yaml | 9 + .../scenarios/shell/pipe/basic/for_loop.yaml | 11 + .../shell/pipe/basic/for_loop_both_sides.yaml | 11 + .../shell/pipe/basic/for_loop_right.yaml | 9 + .../shell/pipe/basic/inside_brace.yaml | 10 + .../scenarios/shell/pipe/basic/linebreak.yaml | 11 + .../scenarios/shell/pipe/basic/multiline.yaml | 11 + .../shell/pipe/basic/no_output_left.yaml | 8 + .../pipe/basic/preserves_empty_lines.yaml | 11 + .../shell/pipe/basic/semicolon_between.yaml | 10 + .../shell/pipe/basic/sequential.yaml | 11 + tests/scenarios/shell/pipe/basic/simple.yaml | 9 + .../shell/pipe/basic/var_isolation.yaml | 12 + .../shell/pipe/basic/variable_expansion.yaml | 10 + tests/scenarios/shell/pipe/errors/left.yaml | 9 + tests/scenarios/shell/pipe/errors/right.yaml | 8 + .../exit_code/dollar_question_in_pipe.yaml | 10 + .../pipe/exit_code/exit_code_from_right.yaml | 8 + .../shell/pipe/exit_code/exit_left.yaml | 9 + .../pipe/exit_code/exit_not_from_middle.yaml | 10 + .../shell/pipe/exit_code/exit_right.yaml | 8 + .../shell/pipe/exit_code/false_true.yaml | 8 + .../shell/pipe/exit_code/four_stage.yaml | 13 + .../exit_code/last_command_determines.yaml | 16 + .../exit_code/left_fails_right_succeeds.yaml | 9 + .../pipe/exit_code/negated_pipeline.yaml | 13 + .../negated_three_stage_nonzero.yaml | 10 + .../exit_code/negated_three_stage_zero.yaml | 10 + .../pipeline_status_via_dollar_question.yaml | 13 + .../exit_code/three_stage_all_nonzero.yaml | 10 + .../exit_code/three_stage_last_nonzero.yaml | 10 + .../shell/pipe/exit_code/true_false.yaml | 8 + .../shell/pipe/logic_ops/and_failure.yaml | 8 + .../shell/pipe/logic_ops/and_success.yaml | 10 + .../shell/pipe/logic_ops/or_failure.yaml | 9 + .../shell/pipe/logic_ops/or_success.yaml | 9 + tests/scenarios/shell/readonly/blocked.yaml | 9 + .../var_expand/basic/adjacent_expansions.yaml | 14 + .../shell/var_expand/basic/adjacent_text.yaml | 10 + .../var_expand/basic/assign_from_unset.yaml | 10 + .../basic/assign_self_reference.yaml | 11 + .../basic/assignment_equals_in_value.yaml | 10 + .../basic/assignment_order_same_line.yaml | 10 + .../var_expand/basic/bare_assignment.yaml | 10 + .../shell/var_expand/basic/braces.yaml | 10 + .../basic/braces_disambiguate_suffix.yaml | 12 + .../var_expand/basic/case_sensitive.yaml | 11 + .../var_expand/basic/chain_assignment.yaml | 11 + .../shell/var_expand/basic/concatenation.yaml | 13 + .../shell/var_expand/basic/empty_value.yaml | 10 + .../basic/exit_status_of_assignment.yaml | 10 + .../var_expand/basic/expansion_mid_word.yaml | 10 + .../shell/var_expand/basic/long_varname.yaml | 10 + .../shell/var_expand/basic/multiple.yaml | 11 + .../basic/multiple_assign_with_expansion.yaml | 11 + .../var_expand/basic/multiple_same_line.yaml | 10 + .../var_expand/basic/newline_in_value.yaml | 12 + .../var_expand/basic/overwrite_value.yaml | 16 + .../shell/var_expand/basic/reassignment.yaml | 13 + .../var_expand/basic/simple_assignment.yaml | 10 + .../basic/special_chars_in_value.yaml | 13 + .../shell/var_expand/basic/tab_in_value.yaml | 7 + .../var_expand/basic/underscore_in_name.yaml | 12 + .../var_expand/basic/unset_expands_empty.yaml | 9 + .../basic/with_spaces_in_value.yaml | 10 + .../blocked_features/all_params.yaml | 10 + .../blocked_features/all_params_star.yaml | 10 + .../blocked_features/alternative.yaml | 11 + .../var_expand/blocked_features/append.yaml | 11 + .../blocked_features/arithmetic.yaml | 10 + .../var_expand/blocked_features/array.yaml | 10 + .../blocked_features/array_index.yaml | 10 + .../blocked_features/array_index_assign.yaml | 10 + .../blocked_features/assign_default.yaml | 10 + .../blocked_features/case_conversion.yaml | 11 + .../blocked_features/command_sub.yaml | 10 + .../command_sub_backtick.yaml | 10 + .../blocked_features/default_value.yaml | 10 + .../blocked_features/error_unset.yaml | 10 + .../var_expand/blocked_features/indirect.yaml | 11 + .../blocked_features/param_count.yaml | 10 + .../blocked_features/positional_params.yaml | 10 + .../blocked_features/prefix_list.yaml | 12 + .../blocked_features/prefix_removal.yaml | 11 + .../var_expand/blocked_features/replace.yaml | 11 + .../blocked_features/script_name.yaml | 10 + .../blocked_features/string_length.yaml | 11 + .../blocked_features/substring.yaml | 11 + .../blocked_features/suffix_removal.yaml | 11 + .../blocked_variables/assignment.yaml | 11 + .../blocked_variables/background_pid.yaml | 10 + .../var_expand/blocked_variables/euid.yaml | 10 + .../var_expand/blocked_variables/gid.yaml | 10 + .../blocked_variables/in_braces.yaml | 10 + .../blocked_variables/last_argument.yaml | 12 + .../var_expand/blocked_variables/lineno.yaml | 10 + .../var_expand/blocked_variables/pid.yaml | 10 + .../var_expand/blocked_variables/ppid.yaml | 10 + .../var_expand/blocked_variables/random.yaml | 10 + .../blocked_variables/shell_options.yaml | 10 + .../var_expand/blocked_variables/srandom.yaml | 10 + .../blocked_variables/stops_execution.yaml | 14 + .../var_expand/blocked_variables/uid.yaml | 10 + .../quoting/backslash_dquote_in_dquotes.yaml | 9 + .../quoting/backslash_escaping.yaml | 9 + .../quoting/backslash_in_double_quotes.yaml | 11 + .../backslash_nonspecial_in_dquotes.yaml | 9 + .../quoting/backslash_normal_chars.yaml | 9 + .../quoting/backslash_special_chars.yaml | 9 + .../quoting/braces_in_double_quotes.yaml | 10 + .../quoting/double_quote_adjacent_words.yaml | 11 + .../double_quote_backslash_backtick.yaml | 9 + .../double_quote_backslash_dollar.yaml | 12 + .../double_quote_backslash_nonspecial.yaml | 9 + .../double_quote_hash_not_comment.yaml | 11 + .../quoting/double_quote_multiline.yaml | 13 + .../quoting/double_quote_multiple_vars.yaml | 15 + .../quoting/double_quote_no_glob.yaml | 9 + .../double_quote_operators_literal.yaml | 15 + .../double_quote_preserves_single_quotes.yaml | 9 + .../double_quote_preserves_whitespace.yaml | 10 + .../quoting/double_quote_unset_var.yaml | 11 + .../quoting/double_quote_with_unquoted.yaml | 11 + .../double_quotes_backslash_rules.yaml | 9 + .../quoting/double_quotes_expand.yaml | 10 + .../double_quotes_line_continuation.yaml | 11 + .../double_quotes_preserve_spaces.yaml | 10 + .../quoting/empty_quoted_args_preserved.yaml | 11 + .../expansion_backslashes_literal.yaml | 12 + .../quoting/mixed_adjacent_quotes.yaml | 12 + .../var_expand/quoting/mixed_quoting.yaml | 10 + .../var_expand/quoting/quotes_in_value.yaml | 13 + .../quoting/single_quote_adjacent_words.yaml | 11 + .../quoting/single_quote_concat.yaml | 9 + .../quoting/single_quote_empty.yaml | 8 + .../quoting/single_quote_empty_arg.yaml | 11 + .../single_quote_hash_not_comment.yaml | 11 + .../quoting/single_quote_multiline.yaml | 13 + .../single_quote_no_var_expansion.yaml | 10 + .../single_quote_operators_literal.yaml | 15 + .../single_quote_preserves_backslashes.yaml | 9 + .../single_quote_preserves_double_quotes.yaml | 9 + .../single_quote_preserves_whitespace.yaml | 9 + .../quoting/single_quote_special_chars.yaml | 9 + .../quoting/single_quote_with_unquoted.yaml | 11 + .../quoting/single_quotes_no_expand.yaml | 10 + .../var_expand/special_variables/status.yaml | 13 + .../special_variables/status_after_and.yaml | 16 + .../status_after_assignment.yaml | 13 + .../special_variables/status_after_echo.yaml | 11 + .../status_after_negation.yaml | 13 + .../special_variables/status_after_or.yaml | 16 + .../special_variables/status_after_pipe.yaml | 13 + .../status_after_unknown_cmd.yaml | 11 + .../status_brace_syntax.yaml | 13 + .../status_capture_in_var.yaml | 13 + .../special_variables/status_chained.yaml | 15 + .../special_variables/status_in_for_loop.yaml | 19 + .../special_variables/status_in_string.yaml | 13 + .../special_variables/status_initial.yaml | 9 + .../status_multiple_captures.yaml | 15 + tests/scenarios_test.go | 392 ++++++++++++++ 851 files changed, 12495 insertions(+) create mode 100644 .DS_Store create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 COMMANDS.md create mode 100644 README.md create mode 100644 SHELL_FEATURES.md create mode 100644 interp/allowed_paths.go create mode 100644 interp/allowed_paths_internal_test.go create mode 100644 interp/allowed_paths_test.go create mode 100644 interp/api.go create mode 100644 interp/builtins/break_continue.go create mode 100644 interp/builtins/builtins.go create mode 100644 interp/builtins/cat.go create mode 100644 interp/builtins/echo.go create mode 100644 interp/builtins/exit.go create mode 100644 interp/builtins/true_false.go create mode 100644 interp/handler.go create mode 100644 interp/handler_exec.go create mode 100644 interp/runner.go create mode 100644 interp/validate.go create mode 100644 interp/vars.go create mode 100644 tests/scenarios/cmd/cat/basic/concat_order.yaml create mode 100644 tests/scenarios/cmd/cat/basic/dash_no_stdin.yaml create mode 100644 tests/scenarios/cmd/cat/basic/dash_with_heredoc.yaml create mode 100644 tests/scenarios/cmd/cat/basic/empty_file.yaml create mode 100644 tests/scenarios/cmd/cat/basic/file.yaml create mode 100644 tests/scenarios/cmd/cat/basic/heredoc.yaml create mode 100644 tests/scenarios/cmd/cat/basic/heredoc_multiline.yaml create mode 100644 tests/scenarios/cmd/cat/basic/multiple_files.yaml create mode 100644 tests/scenarios/cmd/cat/basic/no_stdin.yaml create mode 100644 tests/scenarios/cmd/cat/basic/pipe_chain.yaml create mode 100644 tests/scenarios/cmd/cat/basic/piped_stdin.yaml create mode 100644 tests/scenarios/cmd/cat/basic/single_file.yaml create mode 100644 tests/scenarios/cmd/cat/basic/special_chars_in_file.yaml create mode 100644 tests/scenarios/cmd/cat/errors/is_directory.yaml create mode 100644 tests/scenarios/cmd/cat/errors/is_directory_windows.yaml create mode 100644 tests/scenarios/cmd/cat/errors/multiple_all_fail.yaml create mode 100644 tests/scenarios/cmd/cat/errors/multiple_all_fail_windows.yaml create mode 100644 tests/scenarios/cmd/cat/errors/multiple_first_fails.yaml create mode 100644 tests/scenarios/cmd/cat/errors/multiple_first_fails_windows.yaml create mode 100644 tests/scenarios/cmd/cat/errors/multiple_second_fails.yaml create mode 100644 tests/scenarios/cmd/cat/errors/multiple_second_fails_windows.yaml create mode 100644 tests/scenarios/cmd/cat/errors/nonexistent_continues.yaml create mode 100644 tests/scenarios/cmd/cat/errors/nonexistent_continues_windows.yaml create mode 100644 tests/scenarios/cmd/cat/errors/nonexistent_file.yaml create mode 100644 tests/scenarios/cmd/cat/errors/nonexistent_file_windows.yaml create mode 100644 tests/scenarios/cmd/cat/errors/redirect_nonexistent.yaml create mode 100644 tests/scenarios/cmd/cat/errors/redirect_nonexistent_windows.yaml create mode 100644 tests/scenarios/cmd/echo/basic/empty_string.yaml create mode 100644 tests/scenarios/cmd/echo/basic/exit_code.yaml create mode 100644 tests/scenarios/cmd/echo/basic/multiple_args.yaml create mode 100644 tests/scenarios/cmd/echo/basic/multiple_calls.yaml create mode 100644 tests/scenarios/cmd/echo/basic/multiple_hyphens.yaml create mode 100644 tests/scenarios/cmd/echo/basic/newline_in_double_quotes.yaml create mode 100644 tests/scenarios/cmd/echo/basic/no_args.yaml create mode 100644 tests/scenarios/cmd/echo/basic/numeric_args.yaml create mode 100644 tests/scenarios/cmd/echo/basic/simple.yaml create mode 100644 tests/scenarios/cmd/echo/basic/single_hyphen.yaml create mode 100644 tests/scenarios/cmd/echo/basic/single_hyphen_with_text.yaml create mode 100644 tests/scenarios/cmd/echo/basic/tab_in_arg.yaml create mode 100644 tests/scenarios/cmd/echo/basic/whitespace_in_quotes.yaml create mode 100644 tests/scenarios/cmd/echo/literal/backslash_not_interpreted.yaml create mode 100644 tests/scenarios/cmd/echo/literal/dash_E_is_literal.yaml create mode 100644 tests/scenarios/cmd/echo/literal/dash_n_is_literal.yaml create mode 100644 tests/scenarios/cmd/echo/literal/double_quote_escapes.yaml create mode 100644 tests/scenarios/cmd/echo/literal/mixed_flags_literal.yaml create mode 100644 tests/scenarios/cmd/echo/literal/special_chars.yaml create mode 100644 tests/scenarios/cmd/echo/shell_features/in_for_loop.yaml create mode 100644 tests/scenarios/cmd/echo/shell_features/pipe.yaml create mode 100644 tests/scenarios/cmd/echo/shell_features/variable_expansion.yaml create mode 100644 tests/scenarios/cmd/exit/basic/default_after_true.yaml create mode 100644 tests/scenarios/cmd/exit/basic/double_dash_separator.yaml create mode 100644 tests/scenarios/cmd/exit/basic/no_args.yaml create mode 100644 tests/scenarios/cmd/exit/basic/no_args_last_code.yaml create mode 100644 tests/scenarios/cmd/exit/basic/overrides_previous_status.yaml create mode 100644 tests/scenarios/cmd/exit/basic/preserves_all_output.yaml create mode 100644 tests/scenarios/cmd/exit/basic/preserves_stdout.yaml create mode 100644 tests/scenarios/cmd/exit/basic/stops_execution.yaml create mode 100644 tests/scenarios/cmd/exit/basic/zero.yaml create mode 100644 tests/scenarios/cmd/exit/codes/1.yaml create mode 100644 tests/scenarios/cmd/exit/codes/126.yaml create mode 100644 tests/scenarios/cmd/exit/codes/127.yaml create mode 100644 tests/scenarios/cmd/exit/codes/17.yaml create mode 100644 tests/scenarios/cmd/exit/codes/2.yaml create mode 100644 tests/scenarios/cmd/exit/codes/255.yaml create mode 100644 tests/scenarios/cmd/exit/codes/256_wraps.yaml create mode 100644 tests/scenarios/cmd/exit/codes/42.yaml create mode 100644 tests/scenarios/cmd/exit/codes/large_wraps.yaml create mode 100644 tests/scenarios/cmd/exit/codes/negative.yaml create mode 100644 tests/scenarios/cmd/exit/errors/float.yaml create mode 100644 tests/scenarios/cmd/exit/errors/invalid_continues.yaml create mode 100644 tests/scenarios/cmd/exit/errors/invalid_string.yaml create mode 100644 tests/scenarios/cmd/exit/errors/multiple_args.yaml create mode 100644 tests/scenarios/cmd/exit/errors/multiple_args_continues.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/after_failed_command.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/after_pipeline.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/in_and_chain.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/in_brace_group.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/in_for_loop.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/in_or_chain.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/in_semicolon_chain.yaml create mode 100644 tests/scenarios/cmd/exit/shell_features/with_variable.yaml create mode 100644 tests/scenarios/cmd/false/basic/after_true.yaml create mode 100644 tests/scenarios/cmd/false/basic/multiple.yaml create mode 100644 tests/scenarios/cmd/false/basic/no_stdout.yaml create mode 100644 tests/scenarios/cmd/false/basic/standalone.yaml create mode 100644 tests/scenarios/cmd/false/basic/with_arguments.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/and_chain.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/in_brace_group.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/in_for_loop.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/negation.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/or_and_recovery.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/pipe_left.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/pipe_right.yaml create mode 100644 tests/scenarios/cmd/false/control_flow/semicolon_chain.yaml create mode 100644 tests/scenarios/cmd/false/exit_status/exit_status_captured.yaml create mode 100644 tests/scenarios/cmd/false/exit_status/exit_status_in_chain.yaml create mode 100644 tests/scenarios/cmd/true/basic/after_false.yaml create mode 100644 tests/scenarios/cmd/true/basic/multiple.yaml create mode 100644 tests/scenarios/cmd/true/basic/no_stdout.yaml create mode 100644 tests/scenarios/cmd/true/basic/standalone.yaml create mode 100644 tests/scenarios/cmd/true/basic/with_arguments.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/and_chain.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/and_or_recovery.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/in_brace_group.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/in_for_loop.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/loop_preserves_zero.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/negation.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/or_skips.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/pipe_left.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/pipe_right.yaml create mode 100644 tests/scenarios/cmd/true/control_flow/semicolon_chain.yaml create mode 100644 tests/scenarios/cmd/true/exit_status/exit_status_captured.yaml create mode 100644 tests/scenarios/cmd/true/exit_status/exit_status_in_chain.yaml create mode 100644 tests/scenarios/cmd/true/exit_status/resets_failure.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/basic/after_echo.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/basic/before_echo.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/basic/multiple_consecutive.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/basic/multiword_name.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/basic/simple.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/basic/underscore_name.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/bash.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/chmod.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/cp.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/curl.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/grep.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/ls.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/mv.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/python.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/rm.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/sed.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/sh.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/common_progs/wget.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/exit_code/and_operator_skips.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/exit_code/and_or_chain.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/exit_code/exit_code_127.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/exit_code/exit_code_captured.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/exit_code/or_chain.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/exit_code/or_operator_continues.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/exit_code/semicolon_continues.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/with_args/args.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/with_args/flags.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/with_args/quoted_args.yaml create mode 100644 tests/scenarios/cmd/unknown_cmd/with_args/variable_arg.yaml create mode 100644 tests/scenarios/shell/allowed_paths/cat_after_blocked_continues.yaml create mode 100644 tests/scenarios/shell/allowed_paths/cat_inside_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/cat_outside_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/default_blocks_all.yaml create mode 100644 tests/scenarios/shell/allowed_paths/dir_itself_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/dotdot_filename_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/dotdot_filename_in_subdir.yaml create mode 100644 tests/scenarios/shell/allowed_paths/empty_allowed_blocks_all.yaml create mode 100644 tests/scenarios/shell/allowed_paths/for_loop_cat_files.yaml create mode 100644 tests/scenarios/shell/allowed_paths/glob_inside_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/glob_no_match_in_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/glob_outside_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/heredoc_unaffected.yaml create mode 100644 tests/scenarios/shell/allowed_paths/multiple_allowed_dirs.yaml create mode 100644 tests/scenarios/shell/allowed_paths/multiple_allowed_one_blocked.yaml create mode 100644 tests/scenarios/shell/allowed_paths/multiple_cat_same_dir.yaml create mode 100644 tests/scenarios/shell/allowed_paths/nonexistent_file_in_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/nonexistent_file_in_allowed_windows.yaml create mode 100644 tests/scenarios/shell/allowed_paths/pipe_inside_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/redirect_from_one_cat_from_other.yaml create mode 100644 tests/scenarios/shell/allowed_paths/redirect_inside_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/redirect_outside_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/redirect_variable_inside.yaml create mode 100644 tests/scenarios/shell/allowed_paths/redirect_variable_outside.yaml create mode 100644 tests/scenarios/shell/allowed_paths/subdir_of_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/symlink_chain_escape.yaml create mode 100644 tests/scenarios/shell/allowed_paths/symlink_escape_to_dir.yaml create mode 100644 tests/scenarios/shell/allowed_paths/symlink_escape_to_dir_windows.yaml create mode 100644 tests/scenarios/shell/allowed_paths/symlink_escape_to_file.yaml create mode 100644 tests/scenarios/shell/allowed_paths/symlink_redirect_escape.yaml create mode 100644 tests/scenarios/shell/allowed_paths/symlink_within_allowed.yaml create mode 100644 tests/scenarios/shell/allowed_paths/traversal_blocked.yaml create mode 100644 tests/scenarios/shell/allowed_paths/traversal_to_sibling.yaml create mode 100644 tests/scenarios/shell/allowed_paths/variable_path_inside.yaml create mode 100644 tests/scenarios/shell/allowed_paths/variable_path_outside.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/heredoc.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_empty_file.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_in_brace.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_in_for_loop.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_multiline.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_overrides_pipe.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_pipe_combination.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_quoted_filename.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_sequential.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_special_chars.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_variable_filename.yaml create mode 100644 tests/scenarios/shell/allowed_redirects/input_with_logic_ops.yaml create mode 100644 tests/scenarios/shell/blocked_commands/arithmetic_cmd.yaml create mode 100644 tests/scenarios/shell/blocked_commands/background.yaml create mode 100644 tests/scenarios/shell/blocked_commands/blocked_after_valid.yaml create mode 100644 tests/scenarios/shell/blocked_commands/c_style_for.yaml create mode 100644 tests/scenarios/shell/blocked_commands/case_statement.yaml create mode 100644 tests/scenarios/shell/blocked_commands/coproc.yaml create mode 100644 tests/scenarios/shell/blocked_commands/declare.yaml create mode 100644 tests/scenarios/shell/blocked_commands/export.yaml create mode 100644 tests/scenarios/shell/blocked_commands/extglob.yaml create mode 100644 tests/scenarios/shell/blocked_commands/function_decl.yaml create mode 100644 tests/scenarios/shell/blocked_commands/if_else.yaml create mode 100644 tests/scenarios/shell/blocked_commands/if_statement.yaml create mode 100644 tests/scenarios/shell/blocked_commands/let.yaml create mode 100644 tests/scenarios/shell/blocked_commands/local.yaml create mode 100644 tests/scenarios/shell/blocked_commands/pipe_all.yaml create mode 100644 tests/scenarios/shell/blocked_commands/process_sub.yaml create mode 100644 tests/scenarios/shell/blocked_commands/readonly.yaml create mode 100644 tests/scenarios/shell/blocked_commands/select_statement.yaml create mode 100644 tests/scenarios/shell/blocked_commands/subshell.yaml create mode 100644 tests/scenarios/shell/blocked_commands/test_clause.yaml create mode 100644 tests/scenarios/shell/blocked_commands/time.yaml create mode 100644 tests/scenarios/shell/blocked_commands/until_loop.yaml create mode 100644 tests/scenarios/shell/blocked_commands/while_loop.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/append_all.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/dup_in.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/dup_out.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/herestring.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/read_write.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/stderr_write.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/write_all.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/write_append.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/write_clobber.yaml create mode 100644 tests/scenarios/shell/blocked_redirects/write_truncate.yaml create mode 100644 tests/scenarios/shell/brace_group/as_and_operand.yaml create mode 100644 tests/scenarios/shell/brace_group/as_or_operand.yaml create mode 100644 tests/scenarios/shell/brace_group/basic.yaml create mode 100644 tests/scenarios/shell/brace_group/chained_with_and.yaml create mode 100644 tests/scenarios/shell/brace_group/chained_with_or.yaml create mode 100644 tests/scenarios/shell/brace_group/deeply_nested.yaml create mode 100644 tests/scenarios/shell/brace_group/effect_with_exit.yaml create mode 100644 tests/scenarios/shell/brace_group/exit_code.yaml create mode 100644 tests/scenarios/shell/brace_group/exit_code_tracking.yaml create mode 100644 tests/scenarios/shell/brace_group/exit_inside.yaml create mode 100644 tests/scenarios/shell/brace_group/multiple_sequential.yaml create mode 100644 tests/scenarios/shell/brace_group/multiple_sequential_newlines.yaml create mode 100644 tests/scenarios/shell/brace_group/nested.yaml create mode 100644 tests/scenarios/shell/brace_group/nested_var_scope.yaml create mode 100644 tests/scenarios/shell/brace_group/newlines.yaml create mode 100644 tests/scenarios/shell/brace_group/only_false.yaml create mode 100644 tests/scenarios/shell/brace_group/only_true.yaml create mode 100644 tests/scenarios/shell/brace_group/single_command.yaml create mode 100644 tests/scenarios/shell/brace_group/var_shared_scope.yaml create mode 100644 tests/scenarios/shell/brace_group/with_and_or.yaml create mode 100644 tests/scenarios/shell/brace_group/with_for_loop.yaml create mode 100644 tests/scenarios/shell/brace_group/with_logic_exit_code.yaml create mode 100644 tests/scenarios/shell/brace_group/with_pipe.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/empty_lines.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/long_semicolon_chain.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/mixed.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/newline_multiple.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/newline_simple.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/only_empty_lines.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/semicolon_multiple.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/semicolon_simple.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/trailing_semicolon.yaml create mode 100644 tests/scenarios/shell/cmd_separator/basic/whitespace_around.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/after_for_loop.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/and_or_with_semicolons.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/braces_and_for_mixed.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/braces_then_command.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/for_loop_body.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/for_loop_newline.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/in_braces.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/multiple_for_semicolons.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/with_exit.yaml create mode 100644 tests/scenarios/shell/cmd_separator/control_flow/with_exit_nonzero.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/continues_after_failure.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/exit_code_custom.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/exit_code_first_fails.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/exit_code_last.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/exit_code_last_of_many.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/exit_code_middle_fails.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/exit_code_second_fails.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/exit_status_chain.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/newline_after_failure.yaml create mode 100644 tests/scenarios/shell/cmd_separator/exit_code/unknown_command_continues.yaml create mode 100644 tests/scenarios/shell/cmd_separator/var_sharing/exit_status_variable.yaml create mode 100644 tests/scenarios/shell/cmd_separator/var_sharing/var_from_brace_group.yaml create mode 100644 tests/scenarios/shell/cmd_separator/var_sharing/var_from_for_loop.yaml create mode 100644 tests/scenarios/shell/cmd_separator/var_sharing/variable_accumulate.yaml create mode 100644 tests/scenarios/shell/cmd_separator/var_sharing/variable_newline.yaml create mode 100644 tests/scenarios/shell/cmd_separator/var_sharing/variable_override.yaml create mode 100644 tests/scenarios/shell/cmd_separator/var_sharing/variable_set_then_use.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/and_fails_then_semicolon.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/and_then_semicolon.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/complex_all_features.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/complex_chain.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/multiple_lists_newlines.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/or_then_semicolon.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/pipe_then_semicolon.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/with_and.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/with_or.yaml create mode 100644 tests/scenarios/shell/cmd_separator/with_ops/with_pipe.yaml create mode 100644 tests/scenarios/shell/comments/after_assignment.yaml create mode 100644 tests/scenarios/shell/comments/after_semicolon.yaml create mode 100644 tests/scenarios/shell/comments/backslash_ending.yaml create mode 100644 tests/scenarios/shell/comments/backslash_ending_with_text.yaml create mode 100644 tests/scenarios/shell/comments/basic.yaml create mode 100644 tests/scenarios/shell/comments/comment_truncates_args.yaml create mode 100644 tests/scenarios/shell/comments/escaped_hash_literal.yaml create mode 100644 tests/scenarios/shell/comments/hash_in_quoted_string.yaml create mode 100644 tests/scenarios/shell/comments/hash_in_word.yaml create mode 100644 tests/scenarios/shell/comments/in_and_or.yaml create mode 100644 tests/scenarios/shell/comments/in_brace_group.yaml create mode 100644 tests/scenarios/shell/comments/in_for_loop.yaml create mode 100644 tests/scenarios/shell/comments/in_for_loop_all_positions.yaml create mode 100644 tests/scenarios/shell/comments/in_pipeline.yaml create mode 100644 tests/scenarios/shell/comments/indented_comments.yaml create mode 100644 tests/scenarios/shell/comments/inline.yaml create mode 100644 tests/scenarios/shell/comments/multiple_consecutive.yaml create mode 100644 tests/scenarios/shell/comments/multiple_hashes.yaml create mode 100644 tests/scenarios/shell/comments/only_comments.yaml create mode 100644 tests/scenarios/shell/comments/standalone_between_commands.yaml create mode 100644 tests/scenarios/shell/empty_script/empty.yaml create mode 100644 tests/scenarios/shell/environment/builtin_ifs_set.yaml create mode 100644 tests/scenarios/shell/environment/empty_by_default.yaml create mode 100644 tests/scenarios/shell/environment/empty_var_vs_unset.yaml create mode 100644 tests/scenarios/shell/environment/env_option_empty_value.yaml create mode 100644 tests/scenarios/shell/environment/env_option_field_splitting.yaml create mode 100644 tests/scenarios/shell/environment/env_option_no_extra_vars.yaml create mode 100644 tests/scenarios/shell/environment/env_option_override.yaml create mode 100644 tests/scenarios/shell/environment/env_option_path_like_value.yaml create mode 100644 tests/scenarios/shell/environment/env_option_special_chars.yaml create mode 100644 tests/scenarios/shell/environment/env_option_vars_accessible.yaml create mode 100644 tests/scenarios/shell/environment/home_not_set.yaml create mode 100644 tests/scenarios/shell/environment/ifs_default_tab.yaml create mode 100644 tests/scenarios/shell/environment/ifs_empty_no_split.yaml create mode 100644 tests/scenarios/shell/environment/ifs_multiple_custom_chars.yaml create mode 100644 tests/scenarios/shell/environment/ifs_tab_only.yaml create mode 100644 tests/scenarios/shell/environment/inline_assignment.yaml create mode 100644 tests/scenarios/shell/environment/inline_assignment_temporary.yaml create mode 100644 tests/scenarios/shell/environment/lang_not_set.yaml create mode 100644 tests/scenarios/shell/environment/multiple_assignments.yaml create mode 100644 tests/scenarios/shell/environment/no_parent_propagation.yaml create mode 100644 tests/scenarios/shell/environment/optind_set_to_one.yaml create mode 100644 tests/scenarios/shell/environment/override_provided.yaml create mode 100644 tests/scenarios/shell/environment/path_not_set.yaml create mode 100644 tests/scenarios/shell/environment/provided_vars_accessible.yaml create mode 100644 tests/scenarios/shell/environment/pwd_is_set.yaml create mode 100644 tests/scenarios/shell/environment/pwd_is_set_windows.yaml create mode 100644 tests/scenarios/shell/environment/shell_not_set.yaml create mode 100644 tests/scenarios/shell/environment/term_not_set.yaml create mode 100644 tests/scenarios/shell/environment/tilde_not_expanded.yaml create mode 100644 tests/scenarios/shell/environment/tilde_path_not_expanded.yaml create mode 100644 tests/scenarios/shell/environment/user_not_set.yaml create mode 100644 tests/scenarios/shell/environment/variable_overwrite.yaml create mode 100644 tests/scenarios/shell/environment/variable_with_spaces.yaml create mode 100644 tests/scenarios/shell/field_splitting/double_quotes_prevent_globbing.yaml create mode 100644 tests/scenarios/shell/field_splitting/double_quotes_prevent_splitting.yaml create mode 100644 tests/scenarios/shell/field_splitting/echo_args_custom_ifs.yaml create mode 100644 tests/scenarios/shell/field_splitting/empty_ifs_no_split_any.yaml create mode 100644 tests/scenarios/shell/field_splitting/empty_quoted_preserved.yaml create mode 100644 tests/scenarios/shell/field_splitting/empty_unquoted_removed.yaml create mode 100644 tests/scenarios/shell/field_splitting/ifs_change_affects_later.yaml create mode 100644 tests/scenarios/shell/field_splitting/ifs_colon_separator.yaml create mode 100644 tests/scenarios/shell/field_splitting/ifs_equals_sign.yaml create mode 100644 tests/scenarios/shell/field_splitting/ifs_pipe_char.yaml create mode 100644 tests/scenarios/shell/field_splitting/ifs_whitespace_coalescing.yaml create mode 100644 tests/scenarios/shell/field_splitting/leading_nonws_ifs_empty_field.yaml create mode 100644 tests/scenarios/shell/field_splitting/leading_trailing_ws_trimmed.yaml create mode 100644 tests/scenarios/shell/field_splitting/literal_not_split.yaml create mode 100644 tests/scenarios/shell/field_splitting/mixed_tabs_spaces_coalesce.yaml create mode 100644 tests/scenarios/shell/field_splitting/mixed_ws_nonws_ifs.yaml create mode 100644 tests/scenarios/shell/field_splitting/multiple_consecutive_nonws_ifs.yaml create mode 100644 tests/scenarios/shell/field_splitting/newline_splitting_default_ifs.yaml create mode 100644 tests/scenarios/shell/field_splitting/nonws_ifs_empty_fields.yaml create mode 100644 tests/scenarios/shell/field_splitting/quoted_empty_preserved_multiple.yaml create mode 100644 tests/scenarios/shell/field_splitting/single_quoted_no_expand.yaml create mode 100644 tests/scenarios/shell/field_splitting/unquoted_var_splits.yaml create mode 100644 tests/scenarios/shell/field_splitting/var_in_for_items.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/and_or_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/brace_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/commands_after.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/commands_before.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/do_as_word.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/do_done_as_words.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/empty_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/empty_list.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/exit_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/exit_nonzero_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/for_as_varname.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/in_as_varname.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/in_as_word.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/iterate_words.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/loop_var_overwrite.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/many_items.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/multiline_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/multiple_loops_sequence.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/negation_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/newline_separated_do.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/pipe_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/reserved_words_as_items.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/semicolon_do.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/single_item.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/single_line_compact.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/special_characters_in_items.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/tab_separated_tokens.yaml create mode 100644 tests/scenarios/shell/for_clause/basic/words_not_assignments.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_after_and.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_after_or.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_before_and.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_before_or.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_default_in_triple_nested.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_exceeds_depth.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_from_brace.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_inner_continues_outer.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_invalid_arg.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_mid_body.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_much_exceeds_depth.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_multiple_args.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_outside_loop.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_simple.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_three_nested_outermost.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_two_in_triple_nested.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_two_nested.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_two_outermost.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_with_arg.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_with_negation.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_zero_arg.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_after_and.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_after_or.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_before_and.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_before_or.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_default_in_triple_nested.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_default_operand.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_exceeds_depth.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_from_brace.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_inner_in_nested.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_invalid_arg.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_much_exceeds_depth.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_multiple_args.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_outside_loop.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_simple.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_three_outermost.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_two_in_triple_nested.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_two_nested.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_two_outermost.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_with_arg.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_with_negation.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_zero_arg.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/break_preserves_zero_after_false.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/continue_preserves_zero_after_false.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/exit_code_after_break.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/exit_code_body_mixed.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/exit_code_empty_list.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/exit_code_last_cmd.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/exit_code_last_iteration.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/exit_code_preserves_previous.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/exit_code_success.yaml create mode 100644 tests/scenarios/shell/for_clause/exit_code/unknown_cmd_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/basic.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/break_inner.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/break_outer.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/break_three_levels.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/continue_inner.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/continue_outer.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/continue_three_levels.yaml create mode 100644 tests/scenarios/shell/for_clause/nested/with_pipe.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/env_var_in_items.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/loop_var_reused.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/nested_inner_var_visible.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/unset_var_empty_expansion.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/var_assigned_in_body.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/var_expansion_in_items.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/var_from_outer_scope.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/var_no_clobber_outer.yaml create mode 100644 tests/scenarios/shell/for_clause/var_scoping/var_persists_after_loop.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/character_range.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/character_set.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/digit_range.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/multiple_brackets.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/negation.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/negation_range.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/no_match_literal.yaml create mode 100644 tests/scenarios/shell/globbing/bracket/no_match_literal_range.yaml create mode 100644 tests/scenarios/shell/globbing/for_loop/iterate_bracket_glob.yaml create mode 100644 tests/scenarios/shell/globbing/for_loop/iterate_glob.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/does_not_match_empty.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/mixed_with_star.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/multiple_question_marks.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/no_match_literal.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/question_then_star.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/single_char_match.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/star_then_question.yaml create mode 100644 tests/scenarios/shell/globbing/question_mark/three_question_marks.yaml create mode 100644 tests/scenarios/shell/globbing/quoting/double_quoted_var_no_glob.yaml create mode 100644 tests/scenarios/shell/globbing/quoting/double_quotes_no_expand.yaml create mode 100644 tests/scenarios/shell/globbing/quoting/single_quotes_no_expand.yaml create mode 100644 tests/scenarios/shell/globbing/quoting/unquoted_var_glob_expands.yaml create mode 100644 tests/scenarios/shell/globbing/star/all_files.yaml create mode 100644 tests/scenarios/shell/globbing/star/basic_match.yaml create mode 100644 tests/scenarios/shell/globbing/star/double_star_same_as_single.yaml create mode 100644 tests/scenarios/shell/globbing/star/in_subdirectory.yaml create mode 100644 tests/scenarios/shell/globbing/star/in_subdirectory_windows.yaml create mode 100644 tests/scenarios/shell/globbing/star/matches_directories.yaml create mode 100644 tests/scenarios/shell/globbing/star/matches_empty_prefix.yaml create mode 100644 tests/scenarios/shell/globbing/star/multiple_patterns.yaml create mode 100644 tests/scenarios/shell/globbing/star/no_match_literal.yaml create mode 100644 tests/scenarios/shell/globbing/star/prefix_and_suffix.yaml create mode 100644 tests/scenarios/shell/globbing/star/prefix_match.yaml create mode 100644 tests/scenarios/shell/globbing/star/skips_dotfiles.yaml create mode 100644 tests/scenarios/shell/globbing/star/star_alone.yaml create mode 100644 tests/scenarios/shell/globbing/star/suffix_match.yaml create mode 100644 tests/scenarios/shell/heredoc/and_logic.yaml create mode 100644 tests/scenarios/shell/heredoc/backslash_handling.yaml create mode 100644 tests/scenarios/shell/heredoc/backslash_quoted_delimiter.yaml create mode 100644 tests/scenarios/shell/heredoc/basic.yaml create mode 100644 tests/scenarios/shell/heredoc/custom_delimiter.yaml create mode 100644 tests/scenarios/shell/heredoc/delimiter_not_substring.yaml create mode 100644 tests/scenarios/shell/heredoc/delimiter_starting_with_dash.yaml create mode 100644 tests/scenarios/shell/heredoc/empty_content.yaml create mode 100644 tests/scenarios/shell/heredoc/in_brace_group.yaml create mode 100644 tests/scenarios/shell/heredoc/in_for_loop.yaml create mode 100644 tests/scenarios/shell/heredoc/line_continuation.yaml create mode 100644 tests/scenarios/shell/heredoc/mixed_content.yaml create mode 100644 tests/scenarios/shell/heredoc/multiline.yaml create mode 100644 tests/scenarios/shell/heredoc/multiple_sequential.yaml create mode 100644 tests/scenarios/shell/heredoc/multivar_expansion.yaml create mode 100644 tests/scenarios/shell/heredoc/numeric_delimiter.yaml create mode 100644 tests/scenarios/shell/heredoc/or_logic.yaml create mode 100644 tests/scenarios/shell/heredoc/partially_quoted_double.yaml create mode 100644 tests/scenarios/shell/heredoc/partially_quoted_single.yaml create mode 100644 tests/scenarios/shell/heredoc/pipe.yaml create mode 100644 tests/scenarios/shell/heredoc/pipe_chain.yaml create mode 100644 tests/scenarios/shell/heredoc/preserves_trailing_spaces.yaml create mode 100644 tests/scenarios/shell/heredoc/quoted_delimiter_no_expansion.yaml create mode 100644 tests/scenarios/shell/heredoc/quoted_no_backslash_interp.yaml create mode 100644 tests/scenarios/shell/heredoc/single_double_quotes_in_content.yaml create mode 100644 tests/scenarios/shell/heredoc/single_line.yaml create mode 100644 tests/scenarios/shell/heredoc/special_chars.yaml create mode 100644 tests/scenarios/shell/heredoc/tabs_preserved.yaml create mode 100644 tests/scenarios/shell/heredoc/var_expansion.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/backslash_handling.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/backslash_quoted.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/basic.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/blank_lines.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/empty.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/in_brace.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/in_for_loop.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/indented_delimiter.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/mixed_indent.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/multiline.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/multiple_tabs.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/partially_quoted.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/pipe.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/quoted_delimiter.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/spaces_not_stripped.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/var_expansion.yaml create mode 100644 tests/scenarios/shell/heredoc_dash/with_logic_ops.yaml create mode 100644 tests/scenarios/shell/inline_var/basic.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_args_expanded_first.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_empty_value.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_multiple_commands.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_no_effect_on_others.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_on_false.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_on_true.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_restore_unset.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_same_var_sequential.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_unset_after.yaml create mode 100644 tests/scenarios/shell/inline_var/inline_value_from_var.yaml create mode 100644 tests/scenarios/shell/inline_var/multiple_inline.yaml create mode 100644 tests/scenarios/shell/inline_var/persistent_on_empty_expansion.yaml create mode 100644 tests/scenarios/shell/inline_var/restore_after.yaml create mode 100644 tests/scenarios/shell/line_continuation/across_and_operator.yaml create mode 100644 tests/scenarios/shell/line_continuation/across_or_operator.yaml create mode 100644 tests/scenarios/shell/line_continuation/across_pipe.yaml create mode 100644 tests/scenarios/shell/line_continuation/basic.yaml create mode 100644 tests/scenarios/shell/line_continuation/empty_continuation_line.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_and_operator.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_assignment.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_assignment_then_echo.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_assignment_value.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_assignment_value_simple.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_brace_keywords.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_command_name.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_echo_args.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_for_item_list.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_for_keywords.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_or_operator.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_pipe_operator.yaml create mode 100644 tests/scenarios/shell/line_continuation/in_variable_name.yaml create mode 100644 tests/scenarios/shell/line_continuation/inside_double_quotes.yaml create mode 100644 tests/scenarios/shell/line_continuation/multiple_consecutive.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/both_succeed.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/chain_all_succeed.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/chain_fails_at_fourth.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/chain_five_succeed.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/chain_middle_fails.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/first_fails_skips_rest.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/left_fails.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/right_fails.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/with_assignment.yaml create mode 100644 tests/scenarios/shell/logic_ops/and_basic_behavior/with_true.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/and_custom_exit_codes.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/and_exit_code_from_right.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/and_preserves_left_exit_code.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/last_executed_pipeline.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/mixed_exit_code_recovery.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/or_both_custom_exit.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/or_exit_code_from_right.yaml create mode 100644 tests/scenarios/shell/logic_ops/exit_code/or_exit_code_left_success.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/all_fail_chain.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/and_or_and_chain.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/and_then_or_failure.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/and_then_or_success.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/brace_left_operand_fails.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/long_alternating_chain.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/multiple_independent_lists.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/or_and_or_chain.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/or_then_and_failure.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/or_then_and_success.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/pipelines_in_list.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/recovery_with_braces.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/semicolons_separate_lists.yaml create mode 100644 tests/scenarios/shell/logic_ops/mixed/three_cmd_fallback.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/both_fail.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/chain_all_fail.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/chain_first_succeeds.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/chain_five_all_fail.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/chain_last_succeeds.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/chain_middle_succeeds.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/left_fails.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/left_succeeds.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/with_assignment.yaml create mode 100644 tests/scenarios/shell/logic_ops/or_basic_behavior/with_true.yaml create mode 100644 tests/scenarios/shell/logic_ops/output/and_no_output_on_skip.yaml create mode 100644 tests/scenarios/shell/logic_ops/output/and_output_accumulates.yaml create mode 100644 tests/scenarios/shell/logic_ops/output/linebreak_after_and.yaml create mode 100644 tests/scenarios/shell/logic_ops/output/linebreak_after_or.yaml create mode 100644 tests/scenarios/shell/logic_ops/output/mixed_output_partial.yaml create mode 100644 tests/scenarios/shell/logic_ops/var_interact/and_exit_status_variable.yaml create mode 100644 tests/scenarios/shell/logic_ops/var_interact/chain_var_propagation.yaml create mode 100644 tests/scenarios/shell/logic_ops/var_interact/exit_status_through_chain.yaml create mode 100644 tests/scenarios/shell/logic_ops/var_interact/or_assignment_persists.yaml create mode 100644 tests/scenarios/shell/logic_ops/var_interact/or_exit_status_variable.yaml create mode 100644 tests/scenarios/shell/logic_ops/var_interact/or_var_visible_on_fallback.yaml create mode 100644 tests/scenarios/shell/negation/basic/exit_status_captured.yaml create mode 100644 tests/scenarios/shell/negation/basic/multiple_negations_sequence.yaml create mode 100644 tests/scenarios/shell/negation/basic/negate_echo.yaml create mode 100644 tests/scenarios/shell/negation/basic/negate_false.yaml create mode 100644 tests/scenarios/shell/negation/basic/negate_true.yaml create mode 100644 tests/scenarios/shell/negation/basic/negate_unknown_cmd.yaml create mode 100644 tests/scenarios/shell/negation/compound/negate_brace_group.yaml create mode 100644 tests/scenarios/shell/negation/compound/negate_brace_multi_cmd.yaml create mode 100644 tests/scenarios/shell/negation/compound/negate_brace_with_false.yaml create mode 100644 tests/scenarios/shell/negation/compound/negate_for_empty_list.yaml create mode 100644 tests/scenarios/shell/negation/compound/negate_for_false_body.yaml create mode 100644 tests/scenarios/shell/negation/compound/negate_for_loop.yaml create mode 100644 tests/scenarios/shell/negation/compound/negate_nested_brace.yaml create mode 100644 tests/scenarios/shell/negation/exit_code/negate_exit_one.yaml create mode 100644 tests/scenarios/shell/negation/exit_code/negate_exit_zero.yaml create mode 100644 tests/scenarios/shell/negation/with_logic_ops/multiple_negations_in_and_chain.yaml create mode 100644 tests/scenarios/shell/negation/with_logic_ops/negate_and_chain.yaml create mode 100644 tests/scenarios/shell/negation/with_logic_ops/negate_false_then_and.yaml create mode 100644 tests/scenarios/shell/negation/with_logic_ops/negate_or_chain.yaml create mode 100644 tests/scenarios/shell/negation/with_logic_ops/negate_true_then_or.yaml create mode 100644 tests/scenarios/shell/negation/with_pipe/negate_pipe_failure.yaml create mode 100644 tests/scenarios/shell/negation/with_pipe/negate_pipe_success.yaml create mode 100644 tests/scenarios/shell/negation/with_pipe/negate_three_stage_pipe.yaml create mode 100644 tests/scenarios/shell/pipe/basic/blank_lines_after_pipe_op.yaml create mode 100644 tests/scenarios/shell/pipe/basic/brace_both_sides.yaml create mode 100644 tests/scenarios/shell/pipe/basic/brace_group_in_pipe.yaml create mode 100644 tests/scenarios/shell/pipe/basic/cat_file.yaml create mode 100644 tests/scenarios/shell/pipe/basic/chain.yaml create mode 100644 tests/scenarios/shell/pipe/basic/echo_ignores_stdin.yaml create mode 100644 tests/scenarios/shell/pipe/basic/empty_echo.yaml create mode 100644 tests/scenarios/shell/pipe/basic/five_stage.yaml create mode 100644 tests/scenarios/shell/pipe/basic/for_loop.yaml create mode 100644 tests/scenarios/shell/pipe/basic/for_loop_both_sides.yaml create mode 100644 tests/scenarios/shell/pipe/basic/for_loop_right.yaml create mode 100644 tests/scenarios/shell/pipe/basic/inside_brace.yaml create mode 100644 tests/scenarios/shell/pipe/basic/linebreak.yaml create mode 100644 tests/scenarios/shell/pipe/basic/multiline.yaml create mode 100644 tests/scenarios/shell/pipe/basic/no_output_left.yaml create mode 100644 tests/scenarios/shell/pipe/basic/preserves_empty_lines.yaml create mode 100644 tests/scenarios/shell/pipe/basic/semicolon_between.yaml create mode 100644 tests/scenarios/shell/pipe/basic/sequential.yaml create mode 100644 tests/scenarios/shell/pipe/basic/simple.yaml create mode 100644 tests/scenarios/shell/pipe/basic/var_isolation.yaml create mode 100644 tests/scenarios/shell/pipe/basic/variable_expansion.yaml create mode 100644 tests/scenarios/shell/pipe/errors/left.yaml create mode 100644 tests/scenarios/shell/pipe/errors/right.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/dollar_question_in_pipe.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/exit_code_from_right.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/exit_left.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/exit_not_from_middle.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/exit_right.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/false_true.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/four_stage.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/last_command_determines.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/left_fails_right_succeeds.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/negated_pipeline.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/negated_three_stage_nonzero.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/negated_three_stage_zero.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/pipeline_status_via_dollar_question.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/three_stage_all_nonzero.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/three_stage_last_nonzero.yaml create mode 100644 tests/scenarios/shell/pipe/exit_code/true_false.yaml create mode 100644 tests/scenarios/shell/pipe/logic_ops/and_failure.yaml create mode 100644 tests/scenarios/shell/pipe/logic_ops/and_success.yaml create mode 100644 tests/scenarios/shell/pipe/logic_ops/or_failure.yaml create mode 100644 tests/scenarios/shell/pipe/logic_ops/or_success.yaml create mode 100644 tests/scenarios/shell/readonly/blocked.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/adjacent_expansions.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/adjacent_text.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/assign_from_unset.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/assign_self_reference.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/assignment_equals_in_value.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/assignment_order_same_line.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/bare_assignment.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/braces.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/braces_disambiguate_suffix.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/case_sensitive.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/chain_assignment.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/concatenation.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/empty_value.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/exit_status_of_assignment.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/expansion_mid_word.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/long_varname.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/multiple.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/multiple_assign_with_expansion.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/multiple_same_line.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/newline_in_value.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/overwrite_value.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/reassignment.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/simple_assignment.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/special_chars_in_value.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/tab_in_value.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/underscore_in_name.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/unset_expands_empty.yaml create mode 100644 tests/scenarios/shell/var_expand/basic/with_spaces_in_value.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/all_params.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/all_params_star.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/alternative.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/append.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/arithmetic.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/array.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/array_index.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/array_index_assign.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/assign_default.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/case_conversion.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/command_sub.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/command_sub_backtick.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/default_value.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/error_unset.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/indirect.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/param_count.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/positional_params.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/prefix_list.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/prefix_removal.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/replace.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/script_name.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/string_length.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/substring.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_features/suffix_removal.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/assignment.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/background_pid.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/euid.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/gid.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/in_braces.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/last_argument.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/lineno.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/pid.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/ppid.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/random.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/shell_options.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/srandom.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/stops_execution.yaml create mode 100644 tests/scenarios/shell/var_expand/blocked_variables/uid.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/backslash_dquote_in_dquotes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/backslash_escaping.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/backslash_in_double_quotes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/backslash_nonspecial_in_dquotes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/backslash_normal_chars.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/backslash_special_chars.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/braces_in_double_quotes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_adjacent_words.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_backslash_backtick.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_backslash_dollar.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_backslash_nonspecial.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_hash_not_comment.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_multiline.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_multiple_vars.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_no_glob.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_operators_literal.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_preserves_single_quotes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_preserves_whitespace.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_unset_var.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quote_with_unquoted.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quotes_backslash_rules.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quotes_expand.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quotes_line_continuation.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/double_quotes_preserve_spaces.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/empty_quoted_args_preserved.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/expansion_backslashes_literal.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/mixed_adjacent_quotes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/mixed_quoting.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/quotes_in_value.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_adjacent_words.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_concat.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_empty.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_empty_arg.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_hash_not_comment.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_multiline.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_no_var_expansion.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_operators_literal.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_preserves_backslashes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_preserves_double_quotes.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_preserves_whitespace.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_special_chars.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quote_with_unquoted.yaml create mode 100644 tests/scenarios/shell/var_expand/quoting/single_quotes_no_expand.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_after_and.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_after_assignment.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_after_echo.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_after_negation.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_after_or.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_after_pipe.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_after_unknown_cmd.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_brace_syntax.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_capture_in_var.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_chained.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_in_for_loop.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_in_string.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_initial.yaml create mode 100644 tests/scenarios/shell/var_expand/special_variables/status_multiple_captures.yaml create mode 100644 tests/scenarios_test.go diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6b927f8740d65c8f47a41363fea02917a6202827 GIT binary patch literal 8196 zcmeHMYitx%6u#f|!HgYXS_>3pvRju|aIsq`$U|_uEiY*W*_LigvF`2+?Z|Yd?#ym^ zq^899HqrP>{DtqIjT(&@gQCAod_~icNFp(6BL49ge=q?(Gk3SK3;m%n26S$6@44sP zd(O-``<;7xri?Kp@>(-v4U937x<{278m`j3b+2F1grcO1C`g~N92?1uIwKj=&RnrO z5CjnjA`nC%h(Hj5AOhDy1nAE8nmEI~&sT#nh(Hj5|78Tc^C3*#qwyjg=hQzsXz)t_ zqLn1}8_lT>@O8pP8ZXjuPJLCf$*TtxRZ%Q4z}0Cz>}@9+FVb;NxjF+^XDB`y#R>)f z(@8JvZ)b>e8jL{%f(Tq00k3wiW(KpE%k;AP{bXjuG#ob;I|HGjvZ{KPI7_S%4B{F_8YvgFg*l26hNHS%n=C)L|0_os}kZ6%GP`hYUmA;)$SyCwoPB!EAsUItxQo)noCTpJD^{((?e_TAot<}2&Q)sb zA4S)4je~}slWGx_x~%lDK3UhWaM4YRRkeI~ z$x14pG49VB?hz#-iu@UkYD7}r75g=M$xd7IENcFW+NdrU#r>YVEvM7S#qvruDk*8P z%b@^q6HRinq&QO7A%Xx`ZIRcBiY9fN>5Oi2(FS>wsO*w@3fyxpte5W)rBv1)9wuiT zE=#EGqVlPDkN4n?wBzm`(XG-eCDa|F*yjwTbNcR40$@p_LlpfVmgJtwR?TqjoF9$5 z8dbH|Gu5hTx<*bfYFJpOCe?0H`G#~9!zvgAdHO9l^2JCAbCLK;K8>8#U-D;ujHP-m zof^FhPG!s3db-fM*g+49pGNxE z>@549oo5%=MO32(3PMM-Ge)L1fAPn3`Sa(stVI09xJcx(z z2p+{_g!Jd|JYK*Fyi90+jqpB!H}MwU#s@fsk8m0v`?#OPclZH6mLa)wI+Agr9La@b z&N6LlkmMfvtNcsSq0!NtteK%}uap$iEnOd61%J&3um0aU^Y{Pj!_*)S` zWqYzcL8>zyW?rowrhYGVuX+2%IrUX&@XK+cemPEb>K}$Q57SmpCtjrEoRWmvzy2ZM Rul%_k-2cJ-{~x{o{{a2Ge~AD9 literal 0 HcmV?d00001 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3aa8fc79 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# pkg/shell — Safe Shell Interpreter + +## Overview + +This is a minimal bash/POSIX like shell interpreter. +Safety is the primary goal. + +This shell is intended to be used by AI Agents. + +## Platform Support + +The shell is supported on Linux, Windows and macOS. + +## Documentation + +- `README.md` and `SHELL_FEATURES.md` must be kept up to date with the implementation. + +## Testing + +- In test scenarios, use `expect.stderr` when possible instead of `stderr_contains`. +- `test_against_local_shell` should be enabled (the default) when the tested feature is bash/POSIX compliant. Only set `test_against_local_shell: false` for features that intentionally diverge from standard bash behavior (e.g. blocked commands, restricted redirects, readonly enforcement). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/COMMANDS.md b/COMMANDS.md new file mode 100644 index 00000000..6bf496ad --- /dev/null +++ b/COMMANDS.md @@ -0,0 +1,13 @@ +# Builtin Commands + +Short reference for builtin commands available in `pkg/shell`. + +| Command | Options | Short description | +| --- | --- | --- | +| `true` | none | Exit with status `0`. | +| `false` | none | Exit with status `1`. | +| `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. | +| `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. | +| `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). | +| `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. | +| `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. | diff --git a/README.md b/README.md new file mode 100644 index 00000000..0810a5fd --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# pkg/shell — Restricted Shell Interpreter + +A restricted shell interpreter designed for AI agents performing SRE investigation tasks. +Safety is the primary design goal: the shell defaults to denying all external command execution +and all filesystem access, requiring explicit opt-in via configuration. + +For the complete list of supported and blocked shell features, see [SHELL_FEATURES.md](SHELL_FEATURES.md). + +## Execution Model + +Scripts are processed in two phases: + +1. **Parse & Validate** — The script is parsed into an AST, then validated against an allowlist of + supported syntax nodes. + +2. **Execute** — The validated AST is interpreted. Commands are dispatched to builtins or to the + configured ExecHandler. File access goes through the configured OpenHandler, which enforces + AllowedPaths restrictions. + +## Shell Features + +See [SHELL_FEATURES.md](SHELL_FEATURES.md) for the complete list of supported and blocked features. + +## Security Model + +Every access path is default-deny: + +| Resource | Default | Opt-in | +|----------------------|----------------------------------|----------------------------------------------| +| External commands | Blocked (exit code 127) | Provide an `ExecHandler` | +| Filesystem access | Blocked | Configure `AllowedPaths` with directory list | +| Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option | +| Output redirections | Blocked at validation (exit code 2) | Not configurable — always blocked | + +**AllowedPaths** restricts all file operations (open, read, readdir, exec) to a set of specified +directories. It is built on Go's `os.Root` API, which uses kernel-level `openat` syscalls +for atomic path validation, making it immune to symlink traversal, TOCTOU races, and `..` escape attacks. + +## Testing + +Tests use a YAML scenario-driven framework located in `tests/scenarios/`. + +Scenarios are organized by feature area: + +``` +tests/scenarios/ +├── cmd/ # builtin command tests (echo, cat, exit, true, ...) +└── shell/ # shell feature tests (pipes, variables, control flow, globbing, ...) +``` + +Good sources of POSIX shell test scenarios: +- [yash POSIX shell test suite](https://github.com/magicant/yash/tree/trunk/tests) + +## Platform Support + +Linux, macOS, and Windows. + +## Tips + +Prompt for generate test scenarios: +``` +Improve pkg/shell/tests scenarios coverage by taking inspiration from pkg/shell/yash_posix_tests + +Notes: +- avoid duplicate test coverage, if you encounter duplicate scenarios, remove or merge them +- create as many new scenarios as possible (no limit), of course they must be valuable +- if some tests fail, keep in mind it's possible that pkg/shell implementation is wrong, and it's fine to fix the implementation +``` + +Prompt for avoid disabling test_against_local_shell when possible +``` +In pkg/shell/tests, for each scenarios with test_against_local_shell is disabled, +examine why test_against_local_shell is disabled. + +test_against_local_shell is usually disabled because the restricted shell doesn't behave as bash + +BUT if it's disabled because of a bug in restricted shell (making it behave differently than bash), +then the restricted shell implementation must be fixed +``` diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md new file mode 100644 index 00000000..0928900c --- /dev/null +++ b/SHELL_FEATURES.md @@ -0,0 +1,93 @@ +# Shell Features Reference + +This document lists every shell feature and whether it is supported (✅) or blocked (❌). +Blocked features are rejected before execution with exit code 2. + +## Builtins + +- ✅ `echo` — prints arguments separated by spaces, followed by a newline +- ✅ `cat` — reads files or stdin (`-`); respects AllowedPaths +- ✅ `true` — exits with code 0 +- ✅ `false` — exits with code 1 +- ✅ `exit [N]` — exits with code N (default: last exit code) +- ✅ `break [N]` / `continue [N]` — loop control +- ❌ All other commands — return exit code 127 with `: not found` unless an ExecHandler is configured + +## Variables + +- ✅ Assignment: `VAR=value` +- ✅ Expansion: `$VAR`, `${VAR}` +- ✅ `$?` — last exit code (the only supported special variable) +- ✅ Inline assignment: `VAR=value command` (scoped to that command) +- ❌ Command substitution: `$(cmd)`, `` `cmd` `` +- ❌ Arithmetic expansion: `$(( expr ))` +- ❌ Array assignment: `arr=(a b c)`, `arr[0]=x` +- ❌ Append assignment: `VAR+=value` +- ❌ Parameter expansion operations: `${#var}`, `${var:-default}`, `${var:=default}`, `${var:?msg}`, `${var:+alt}`, `${var:offset}`, `${var/pattern/repl}`, `${var#prefix}`, `${var%suffix}`, `${!var}`, `${!prefix*}`, case conversion +- ❌ Positional parameters: `$1`–`$9`, `$@`, `$*`, `$#`, `$0` +- ❌ Special variables: `$!`, `$LINENO` + +## Control Flow + +- ✅ `for VAR in WORDS; do CMDS; done` +- ✅ `&&` — AND list (short-circuit) +- ✅ `||` — OR list (short-circuit) +- ✅ `!` — negation (inverts exit code) +- ✅ `{ CMDS; }` — brace group +- ✅ `;` and newline as command separators +- ❌ `if` / `elif` / `else` +- ❌ `while` / `until` +- ❌ `case` +- ❌ `select` +- ❌ C-style for loop: `for (( i=0; i` — write/truncate +- ❌ `>>` — append +- ❌ `&>` — redirect all +- ❌ `&>>` — append all +- ❌ `<>` — read-write +- ❌ `>&N` / `<&N` — file descriptor duplication + +## Quoting and Expansion + +- ✅ Single quotes: `'literal'` +- ✅ Double quotes: `"with $expansion"` +- ✅ Globbing: `*`, `?`, `[abc]`, `[a-z]`, `[!a]` +- ✅ Line continuation: `\` at end of line +- ✅ Comments: `# text` +- ❌ Extended globbing: `@(pat)`, `*(pat)`, etc. +- ❌ Tilde expansion: `~`, `~/path` +- ❌ Process substitution: `<(cmd)`, `>(cmd)` + +## Execution + +- ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories +- ❌ External commands — blocked by default; requires an ExecHandler to be configured and the binary to be within AllowedPaths +- ❌ Background execution: `cmd &` +- ❌ Coprocesses: `coproc` +- ❌ `time` +- ❌ `[[ ... ]]` test expressions +- ❌ `(( ... ))` arithmetic commands +- ❌ `declare`, `export`, `local`, `readonly`, `let` + +## Environment + +- ✅ Empty by default — no parent environment variables are inherited +- ✅ Caller-provided variables via the `Env` option +- ✅ `IFS` is set to space/tab/newline by default +- ❌ No automatic inheritance from the host process +- ❌ `export`, `readonly` are blocked + +## Appendix + +Formating: In each category, supported features should be listed first, and the most useful ones first. diff --git a/interp/allowed_paths.go b/interp/allowed_paths.go new file mode 100644 index 00000000..5688cf5e --- /dev/null +++ b/interp/allowed_paths.go @@ -0,0 +1,163 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package interp + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "strings" +) + +// AllowedPaths restricts file and directory access to the specified directories. +// Paths must be absolute directories that exist. When set, only files within +// these directories can be opened, read, or executed. +// +// When not set (default), all file access is blocked. +// An empty slice also blocks all file access. +// +// The restriction is enforced using os.Root (Go 1.24+), which uses openat +// syscalls for atomic path validation — immune to symlink and ".." traversal attacks. +func AllowedPaths(paths []string) RunnerOption { + return func(r *Runner) error { + cleaned := make([]string, len(paths)) + for i, p := range paths { + abs, err := filepath.Abs(p) + if err != nil { + return fmt.Errorf("AllowedPaths: cannot resolve %q: %w", p, err) + } + info, err := os.Stat(abs) + if err != nil { + return fmt.Errorf("AllowedPaths: cannot stat %q: %w", abs, err) + } + if !info.IsDir() { + return fmt.Errorf("AllowedPaths: %q is not a directory", abs) + } + cleaned[i] = abs + } + r.allowedPaths = cleaned + return nil + } +} + +// findMatchingRoot returns the matching os.Root and relative path for an absolute path. +// It returns false if no root matches. +func findMatchingRoot(absPath string, roots []*os.Root, allowedPaths []string) (*os.Root, string, bool) { + for i, ap := range allowedPaths { + rel, err := filepath.Rel(ap, absPath) + if err != nil { + continue + } + // Check for exact ".." or "....." to detect escapes, but not + // filenames that happen to start with two dots (e.g. "..hidden"). + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + return roots[i], rel, true + } + return nil, "", false +} + +// wrapOpenHandler wraps an OpenHandlerFunc to restrict file opens to allowed paths. +// The file is opened through os.Root for atomic path validation. +func wrapOpenHandler(roots []*os.Root, allowedPaths []string) OpenHandlerFunc { + return func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { + absPath := path + if !filepath.IsAbs(absPath) { + hc := HandlerCtx(ctx) + absPath = filepath.Join(hc.Dir, absPath) + } + + root, relPath, ok := findMatchingRoot(absPath, roots, allowedPaths) + if !ok { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + + return root.OpenFile(relPath, flag, perm) + } +} + +// wrapReadDirHandler returns a ReadDirHandlerFunc that restricts directory reads to allowed paths. +func wrapReadDirHandler(roots []*os.Root, allowedPaths []string) ReadDirHandlerFunc { + return func(ctx context.Context, path string) ([]fs.DirEntry, error) { + absPath := path + if !filepath.IsAbs(absPath) { + hc := HandlerCtx(ctx) + absPath = filepath.Join(hc.Dir, absPath) + } + + root, relPath, ok := findMatchingRoot(absPath, roots, allowedPaths) + if !ok { + return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } + + f, err := root.Open(relPath) + if err != nil { + return nil, err + } + defer f.Close() + entries, err := f.ReadDir(-1) + if err != nil { + return nil, err + } + // os.Root's ReadDir does not guarantee sorted order like os.ReadDir. + // Sort to match POSIX glob expansion expectations. + slices.SortFunc(entries, func(a, b fs.DirEntry) int { + if a.Name() < b.Name() { + return -1 + } + if a.Name() > b.Name() { + return 1 + } + return 0 + }) + return entries, nil + } +} + +// wrapExecHandler wraps an ExecHandlerFunc to restrict command execution to allowed paths. +// It resolves the command to an absolute path, validates it against allowed roots +// using os.Root.Stat for atomic symlink-safe verification, then delegates to next +// for actual execution. Returns exit code 127 if not found. +func wrapExecHandler(roots []*os.Root, allowedPaths []string, next ExecHandlerFunc) ExecHandlerFunc { + return func(ctx context.Context, args []string) error { + hc := HandlerCtx(ctx) + path, err := ExecLookPathDir(hc.Dir, hc.Env, args[0]) + if err != nil { + fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0]) + return ExitStatus(127) + } + + root, relPath, ok := findMatchingRoot(path, roots, allowedPaths) + if !ok { + fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0]) + return ExitStatus(127) + } + + // Validate via os.Root.Stat which uses openat-based resolution, + // atomically rejecting symlinks that escape the root directory. + info, err := root.Stat(relPath) + if err != nil { + fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0]) + return ExitStatus(127) + } + if info.IsDir() { + fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0]) + return ExitStatus(127) + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0]) + return ExitStatus(127) + } + + return next(ctx, args) + } +} diff --git a/interp/allowed_paths_internal_test.go b/interp/allowed_paths_internal_test.go new file mode 100644 index 00000000..e5a75bab --- /dev/null +++ b/interp/allowed_paths_internal_test.go @@ -0,0 +1,150 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package interp + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" +) + +func systemExecAllowedPaths(t *testing.T) []string { + t.Helper() + if runtime.GOOS == "windows" { + return []string{filepath.Join(os.Getenv("SystemRoot"), "System32")} + } + return []string{"/bin", "/usr"} +} + +func runScriptInternal(t *testing.T, script, dir string, opts ...RunnerOption) (stdout, stderr string, exitCode int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + require.NoError(t, err) + + var outBuf, errBuf bytes.Buffer + allOpts := append([]RunnerOption{ + StdIO(nil, &outBuf, &errBuf), + }, opts...) + + runner, err := New(allOpts...) + require.NoError(t, err) + defer runner.Close() + + if dir != "" { + runner.Dir = dir + } + runner.execHandler = func(ctx context.Context, args []string) error { + hc := HandlerCtx(ctx) + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = hc.Dir + cmd.Stdout = hc.Stdout + cmd.Stderr = hc.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return ExitStatus(exitErr.ExitCode()) + } + return err + } + return nil + } + + err = runner.Run(context.Background(), prog) + exitCode = 0 + if err != nil { + var es ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +func TestAllowedPathsExecInside(t *testing.T) { + dir := t.TempDir() + + var script string + if runtime.GOOS == "windows" { + system32 := filepath.Join(os.Getenv("SystemRoot"), "System32") + script = strings.ReplaceAll(filepath.Join(system32, "cmd.exe"), `\`, `/`) + " /c echo hello" + } else { + script = `/bin/echo hello` + } + + stdout, _, exitCode := runScriptInternal(t, script, dir, + AllowedPaths(append([]string{dir}, systemExecAllowedPaths(t)...)), + ) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "hello\n", strings.ReplaceAll(stdout, "\r\n", "\n")) +} + +func TestAllowedPathsExecOutside(t *testing.T) { + dir := t.TempDir() + // Only allow the temp dir, so /bin/echo should be blocked + _, stderr, exitCode := runScriptInternal(t, `/bin/echo hello`, dir, + AllowedPaths([]string{dir}), + ) + assert.Equal(t, 127, exitCode) + assert.Contains(t, stderr, "command not found") +} + +func TestAllowedPathsExecNonexistent(t *testing.T) { + dir := t.TempDir() + _, stderr, exitCode := runScriptInternal(t, `totally_nonexistent_cmd_12345`, dir, + AllowedPaths(append([]string{dir}, systemExecAllowedPaths(t)...)), + ) + assert.Equal(t, 127, exitCode) + assert.Contains(t, stderr, "command not found") +} + +func TestAllowedPathsExecViaPathLookup(t *testing.T) { + dir := t.TempDir() + // "ls" is resolved via PATH (not absolute), but /bin and /usr are not allowed + _, stderr, exitCode := runScriptInternal(t, `ls`, dir, + AllowedPaths([]string{dir}), + ) + assert.Equal(t, 127, exitCode) + assert.Contains(t, stderr, "command not found") +} + +func TestAllowedPathsExecSymlinkEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink test not applicable on Windows") + } + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + require.NoError(t, os.MkdirAll(binDir, 0755)) + + // Create a symlink inside the allowed dir pointing to /bin/echo outside it. + require.NoError(t, os.Symlink("/bin/echo", filepath.Join(binDir, "escape_echo"))) + + // Only allow the temp dir — the symlink target (/bin/echo) is outside. + _, stderr, exitCode := runScriptInternal(t, filepath.Join(binDir, "escape_echo")+" hello", dir, + AllowedPaths([]string{dir}), + ) + assert.Equal(t, 127, exitCode) + assert.Contains(t, stderr, "command not found") +} + +func TestAllowedPathsExecDefaultBlocksAll(t *testing.T) { + dir := t.TempDir() + // No AllowedPaths option — default blocks all exec + _, stderr, exitCode := runScriptInternal(t, `/bin/echo hello`, dir) + assert.Equal(t, 127, exitCode) + assert.Contains(t, stderr, "command not found") +} diff --git a/interp/allowed_paths_test.go b/interp/allowed_paths_test.go new file mode 100644 index 00000000..38b3c6f2 --- /dev/null +++ b/interp/allowed_paths_test.go @@ -0,0 +1,218 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package interp_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/datadog-agent/pkg/shell/interp" +) + +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (stdout, stderr string, exitCode int) { + t.Helper() + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(script), "") + require.NoError(t, err) + + var outBuf, errBuf bytes.Buffer + allOpts := append([]interp.RunnerOption{ + interp.StdIO(nil, &outBuf, &errBuf), + }, opts...) + + runner, err := interp.New(allOpts...) + require.NoError(t, err) + defer runner.Close() + + if dir != "" { + runner.Dir = dir + } + + err = runner.Run(context.Background(), prog) + exitCode = 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +func TestAllowedPathsOption(t *testing.T) { + t.Run("invalid path rejected", func(t *testing.T) { + _, err := interp.New( + interp.AllowedPaths([]string{"/nonexistent/path/that/does/not/exist"}), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "AllowedPaths") + }) + + t.Run("file not directory rejected", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "file.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("test"), 0644)) + + _, err := interp.New( + interp.AllowedPaths([]string{tmpFile}), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) + + t.Run("valid directory accepted", func(t *testing.T) { + dir := t.TempDir() + runner, err := interp.New( + interp.AllowedPaths([]string{dir}), + ) + require.NoError(t, err) + runner.Close() + }) +} + +func TestAllowedPathsCatInside(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world\n"), 0644)) + + stdout, _, exitCode := runScript(t, "cat hello.txt", dir, + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "hello world\n", stdout) +} + +func TestAllowedPathsCatOutside(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "hidden.txt"), []byte("secret"), 0644)) + + catPath := strings.ReplaceAll(filepath.Join(secret, "hidden.txt"), `\`, `/`) + _, stderr, exitCode := runScript(t, "cat "+catPath, allowed, + interp.AllowedPaths([]string{allowed}), + ) + assert.Equal(t, 1, exitCode) + assert.Contains(t, stderr, "permission denied") +} + +func TestAllowedPathsRedirectInside(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("redirected"), 0644)) + + stdout, _, exitCode := runScript(t, "cat < data.txt", dir, + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "redirected", stdout) +} + +func TestAllowedPathsRedirectOutside(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "data.txt"), []byte("secret"), 0644)) + + _, stderr, exitCode := runScript(t, "cat < "+filepath.Join(secret, "data.txt"), allowed, + interp.AllowedPaths([]string{allowed}), + ) + assert.Equal(t, 1, exitCode) + assert.Contains(t, stderr, "permission denied") +} + +func TestAllowedPathsGlobInside(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte(""), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte(""), 0644)) + + stdout, _, exitCode := runScript(t, `echo *.txt`, dir, + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 0, exitCode) + assert.Contains(t, stdout, "a.txt") + assert.Contains(t, stdout, "b.txt") +} + +func TestAllowedPathsGlobOutside(t *testing.T) { + allowed := t.TempDir() + secret := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(secret, "a.txt"), []byte(""), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(secret, "b.txt"), []byte(""), 0644)) + + // Glob on a directory outside allowed paths should return the literal pattern + stdout, _, exitCode := runScript(t, `echo `+filepath.Join(secret, "*.txt"), allowed, + interp.AllowedPaths([]string{allowed}), + ) + assert.Equal(t, 0, exitCode) + assert.Contains(t, stdout, "*.txt") // pattern not expanded +} + +func TestAllowedPathsTraversalBlocked(t *testing.T) { + dir := t.TempDir() + // Even if we try to traverse with .., os.Root should block it + _, stderr, exitCode := runScript(t, `cat ../../etc/passwd`, dir, + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 1, exitCode) + assert.Contains(t, stderr, "permission denied") +} + +func TestAllowedPathsDoubleDotFilename(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "..hidden"), []byte("dotdot content\n"), 0644)) + + stdout, _, exitCode := runScript(t, `cat ..hidden`, dir, + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "dotdot content\n", stdout) +} + +func TestAllowedPathsEmptyBlocksAll(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644)) + + _, stderr, exitCode := runScript(t, "cat test.txt", dir, + interp.AllowedPaths([]string{}), + ) + assert.Equal(t, 1, exitCode) + assert.Contains(t, stderr, "permission denied") +} + +func TestAllowedPathsDefaultBlocksAll(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("works\n"), 0644)) + + // No AllowedPaths option = blocks all file access + _, stderr, exitCode := runScript(t, "cat test.txt", dir) + assert.Equal(t, 1, exitCode) + assert.Contains(t, stderr, "permission denied") +} + + +func TestAllowedPathsClose(t *testing.T) { + dir := t.TempDir() + runner, err := interp.New( + interp.AllowedPaths([]string{dir}), + ) + require.NoError(t, err) + + // Trigger Reset to open roots + parser := syntax.NewParser() + prog, _ := parser.Parse(strings.NewReader("true"), "") + _ = runner.Run(context.Background(), prog) + + // Close should not panic, even if called twice + require.NoError(t, runner.Close()) + require.NoError(t, runner.Close()) +} diff --git a/interp/api.go b/interp/api.go new file mode 100644 index 00000000..56462855 --- /dev/null +++ b/interp/api.go @@ -0,0 +1,415 @@ +// Copyright (c) 2017, Daniel Martí +// See LICENSE for licensing information + +// Package interp implements a restricted shell interpreter designed for +// safe, sandboxed execution. It supports a subset of Bash syntax with +// many features intentionally blocked (see [validateNode]). +// +// The interpreter behaves like a non-interactive shell. External command +// execution and filesystem access are denied by default and must be +// explicitly enabled via [RunnerOption] functions. +package interp + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" +) + +// A Runner interprets shell programs. It can be reused, but it is not safe for +// concurrent use. Use [New] to build a new Runner. +// +// Runner's exported fields are meant to be configured via [RunnerOption]; +// once a Runner has been created, the fields should be treated as read-only. +type Runner struct { + // Env specifies the initial environment for the interpreter, which must + // not be nil. It can only be set via [Env]. + Env expand.Environ + + // writeEnv overlays [Runner.Env] so that we can write environment variables + // as an overlay. + writeEnv expand.WriteEnviron + + // Dir specifies the working directory of the command, which must be an + // absolute path. + Dir string + + // Params are the current shell parameters, e.g. from running a shell + // file. Note: positional parameter expansion ($@, $*, $1, etc.) is + // blocked by the AST validator in this restricted interpreter. + Params []string + + // execHandler is responsible for executing programs. It must not be nil. + execHandler ExecHandlerFunc + + // openHandler is a function responsible for opening files. It must not be nil. + openHandler OpenHandlerFunc + + // readDirHandler is a function responsible for reading directories during + // glob expansion. It must be non-nil. + readDirHandler ReadDirHandlerFunc + + stdin *os.File // e.g. the read end of a pipe + stdout io.Writer + stderr io.Writer + + ecfg *expand.Config + ectx context.Context // just so that subshell can use it again + + // didReset remembers whether the runner has ever been reset. This is + // used so that Reset is automatically called when running any program + // or node for the first time on a Runner. + didReset bool + + usedNew bool + + filename string // only if Node was a File + + // >0 to break or continue out of N enclosing loops + breakEnclosing, contnEnclosing int + + inLoop bool + + // The current and last exit statuses. They can only be different if + // the interpreter is in the middle of running a statement. In that + // scenario, 'exit' is the status for the current statement being run, + // and 'lastExit' corresponds to the previous statement that was run. + exit exitStatus + lastExit exitStatus + + lastExpandExit exitStatus // used to surface exit statuses while expanding fields + + // allowedPaths restricts file/directory access to these directories. + // Empty (default) blocks all file access; populate via AllowedPaths option. + allowedPaths []string + // roots holds opened os.Root instances, one per allowedPaths entry. + roots []*os.Root + + origDir string + origParams []string + origStdin *os.File + origStdout io.Writer + origStderr io.Writer + +} + +// exitStatus holds the state of the shell after running one command. +// Beyond the exit status code, it also holds whether the shell should return or exit, +// as well as any Go error values that should be given back to the user. +type exitStatus struct { + // code is the exit status code. + code uint8 + + exiting bool // whether the current shell is exiting + fatalExit bool // whether the current shell is exiting due to a fatal error; err below must not be nil + + // err is a fatal error if fatal is true, or a non-fatal custom error from a handler. + // Used so that running a single statement with a custom handler + // which returns a non-fatal Go error, such as a Go error wrapping [NewExitStatus], + // can be returned by [Runner.Run] without being lost entirely. + err error +} + +func (e *exitStatus) ok() bool { return e.code == 0 } + +func (e *exitStatus) oneIf(b bool) { + if b { + e.code = 1 + } else { + e.code = 0 + } +} + +func (e *exitStatus) fatal(err error) { + if !e.fatalExit && err != nil { + e.exiting = true + e.fatalExit = true + e.err = err + if e.code == 0 { + e.code = 1 + } + } +} + +func (e *exitStatus) fromHandlerError(err error) { + if err != nil { + var es ExitStatus + if errors.As(err, &es) { + e.err = err + e.code = uint8(es) + } else { + e.fatal(err) // handler's custom fatal error + } + } else { + e.code = 0 + } +} + +// New creates a new Runner, applying a number of options. If applying any of +// the options results in an error, it is returned. +// +// Any unset options fall back to their defaults. For example, not supplying the +// environment defaults to an empty environment (no host env inherited), and not +// supplying the standard output writer means that the output will be discarded. +func New(opts ...RunnerOption) (*Runner, error) { + r := &Runner{ + usedNew: true, + openHandler: defaultOpenHandler(), + readDirHandler: defaultReadDirHandler(), + } + for _, opt := range opts { + if err := opt(r); err != nil { + return nil, err + } + } + + // Set the default fallbacks, if necessary. + // Default to an empty environment to avoid propagating parent env vars. + if r.Env == nil { + r.Env = expand.ListEnviron() + } + if r.Dir == "" { + dir, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("could not get current dir: %w", err) + } + r.Dir = dir + } + if r.stdout == nil || r.stderr == nil { + StdIO(r.stdin, r.stdout, r.stderr)(r) + } + return r, nil +} + +// RunnerOption can be passed to [New] to alter a [Runner]'s behaviour. +type RunnerOption func(*Runner) error + +func stdinFile(r io.Reader) (*os.File, error) { + switch r := r.(type) { + case *os.File: + return r, nil + case nil: + return nil, nil + default: + pr, pw, err := os.Pipe() + if err != nil { + return nil, err + } + go func() { + io.Copy(pw, r) + pw.Close() + }() + return pr, nil + } +} + +// Env sets the initial environment for the interpreter. Each pair must be in +// "KEY=value" format. If this option is not used, the interpreter starts with +// an empty environment (no host environment variables are inherited). +func Env(pairs ...string) RunnerOption { + return func(r *Runner) error { + r.Env = expand.ListEnviron(pairs...) + return nil + } +} + +// StdIO configures an interpreter's standard input, standard output, and +// standard error. If out or err are nil, they default to a writer that discards +// the output. +// +// Note that providing a non-nil standard input other than [*os.File] will require +// an [os.Pipe] and spawning a goroutine to copy into it, +// as an [os.File] is the only way to share a reader with subprocesses. +// This may cause the interpreter to consume the entire reader. +// See [os/exec.Cmd.Stdin]. +// +// When providing an [*os.File] as standard input, consider using an [os.Pipe] +// as it has the best chance to support cancellable reads via [os.File.SetReadDeadline], +// so that cancelling the runner's context can stop a blocked standard input read. +func StdIO(in io.Reader, out, err io.Writer) RunnerOption { + return func(r *Runner) error { + stdin, _err := stdinFile(in) + if _err != nil { + return _err + } + r.stdin = stdin + if out == nil { + out = io.Discard + } + r.stdout = out + if err == nil { + err = io.Discard + } + r.stderr = err + return nil + } +} + +// Reset returns a runner to its initial state, right before the first call to +// Run or Reset. +// +// Typically, this function only needs to be called if a runner is reused to run +// multiple programs non-incrementally. Not calling Reset between each run will +// mean that the shell state will be kept, including variables, options, and the +// current directory. +func (r *Runner) Reset() { + if !r.usedNew { + panic("use interp.New to construct a Runner") + } + if !r.didReset { + r.origDir = r.Dir + r.origParams = r.Params + r.origStdin = r.stdin + r.origStdout = r.stdout + r.origStderr = r.stderr + + if r.execHandler == nil { + r.execHandler = noExecHandler() + } + // Open os.Root handles and wrap handlers for path restriction. + // Default: block all file access (empty allowedPaths). + if r.roots == nil { + r.roots = make([]*os.Root, len(r.allowedPaths)) + for i, p := range r.allowedPaths { + root, err := os.OpenRoot(p) + if err != nil { + for _, prev := range r.roots[:i] { + prev.Close() + } + r.exit.fatal(fmt.Errorf("AllowedPaths: cannot open root %q: %w", p, err)) + return + } + r.roots[i] = root + } + r.openHandler = wrapOpenHandler(r.roots, r.allowedPaths) + r.readDirHandler = wrapReadDirHandler(r.roots, r.allowedPaths) + r.execHandler = wrapExecHandler(r.roots, r.allowedPaths, r.execHandler) + } + } + // reset the internal state + *r = Runner{ + Env: r.Env, + execHandler: r.execHandler, + openHandler: r.openHandler, + readDirHandler: r.readDirHandler, + + allowedPaths: r.allowedPaths, + roots: r.roots, + + // These can be set by functions like [Dir] or [Params], but + // builtins can overwrite them; reset the fields to whatever the + // constructor set up. + Dir: r.origDir, + Params: r.origParams, + stdin: r.origStdin, + stdout: r.origStdout, + stderr: r.origStderr, + + origDir: r.origDir, + origParams: r.origParams, + origStdin: r.origStdin, + origStdout: r.origStdout, + origStderr: r.origStderr, + + usedNew: r.usedNew, + } + r.writeEnv = &overlayEnviron{parent: r.Env} + r.setVarString("PWD", r.Dir) + r.setVarString("IFS", " \t\n") + r.setVarString("OPTIND", "1") + + r.didReset = true +} + +// ExitStatus is a non-zero status code resulting from running a shell node. +type ExitStatus uint8 + +func (s ExitStatus) Error() string { return fmt.Sprintf("exit status %d", s) } + +// Run interprets a node, which can be a [*File], [*Stmt], or [Command]. If a non-nil +// error is returned, it will typically contain a command's exit status, which +// can be retrieved with [errors.As] and [ExitStatus]. +// +// Run can be called multiple times synchronously to interpret programs +// incrementally. To reuse a [Runner] without keeping the internal shell state, +// call Reset. +func (r *Runner) Run(ctx context.Context, node syntax.Node) error { + if !r.didReset { + r.Reset() + } + r.fillExpandConfig(ctx) + if err := validateNode(node); err != nil { + fmt.Fprintln(r.stderr, err) + return ExitStatus(2) + } + r.exit = exitStatus{} + r.filename = "" + switch node := node.(type) { + case *syntax.File: + r.filename = node.Name + r.stmts(ctx, node.Stmts) + case *syntax.Stmt: + r.stmt(ctx, node) + case syntax.Command: + r.cmd(ctx, node) + default: + return fmt.Errorf("node can only be File, Stmt, or Command: %T", node) + } + // Return the first of: a fatal error, a non-fatal handler error, or the exit code. + if err := r.exit.err; err != nil { + return err + } + if code := r.exit.code; code != 0 { + return ExitStatus(code) + } + return nil +} + +// Close releases resources held by the Runner, such as os.Root file descriptors +// opened by AllowedPaths. It is safe to call Close multiple times. +func (r *Runner) Close() error { + for _, root := range r.roots { + root.Close() + } + r.roots = nil + return nil +} + +// subshell creates a child Runner that inherits the parent's state. +// If background is false, the child shares the parent's environment overlay +// without copying, which is more efficient but must not be used concurrently. +func (r *Runner) subshell(background bool) *Runner { + if !r.didReset { + r.Reset() + } + // Keep in sync with the Runner type. Manually copy fields, to not copy + // sensitive ones, and to do deep copies of slices. + r2 := &Runner{ + Dir: r.Dir, + Params: r.Params, + execHandler: r.execHandler, + openHandler: r.openHandler, + readDirHandler: r.readDirHandler, + + allowedPaths: r.allowedPaths, + roots: r.roots, // safe: os.Root is goroutine-safe + + stdin: r.stdin, + stdout: r.stdout, + stderr: r.stderr, + filename: r.filename, + usedNew: r.usedNew, + exit: r.exit, + lastExit: r.lastExit, + + } + r2.writeEnv = newOverlayEnviron(r.writeEnv, background) + r2.fillExpandConfig(r.ectx) + r2.didReset = true + return r2 +} diff --git a/interp/builtins/break_continue.go b/interp/builtins/break_continue.go new file mode 100644 index 00000000..56c2c1bd --- /dev/null +++ b/interp/builtins/break_continue.go @@ -0,0 +1,51 @@ +// Copyright (c) Datadog, Inc. +// See LICENSE for licensing information + +package builtins + +import ( + "context" + "strconv" +) + +func builtinBreak(_ context.Context, callCtx *CallContext, args []string) Result { + return loopControl(callCtx, "break", args) +} + +func builtinContinue(_ context.Context, callCtx *CallContext, args []string) Result { + return loopControl(callCtx, "continue", args) +} + +func loopControl(callCtx *CallContext, name string, args []string) Result { + if !callCtx.InLoop { + callCtx.Errf("%s is only useful in a loop\n", name) + return Result{} + } + + n := 1 + switch len(args) { + case 0: + case 1: + parsed, err := strconv.Atoi(args[0]) + if err != nil { + callCtx.Errf("%s: %s: numeric argument required\n", name, args[0]) + return Result{Code: 128, Exiting: true} + } + if parsed < 1 { + callCtx.Errf("%s: %s: loop count out of range\n", name, args[0]) + return Result{Code: 1, BreakN: 1} + } + n = parsed + default: + callCtx.Errf("%s: too many arguments\n", name) + return Result{Code: 1, BreakN: 1} + } + + var r Result + if name == "break" { + r.BreakN = n + } else { + r.ContinueN = n + } + return r +} diff --git a/interp/builtins/builtins.go b/interp/builtins/builtins.go new file mode 100644 index 00000000..45ab8761 --- /dev/null +++ b/interp/builtins/builtins.go @@ -0,0 +1,78 @@ +// Copyright (c) Datadog, Inc. +// See LICENSE for licensing information + +package builtins + +import ( + "context" + "fmt" + "io" + "os" +) + +// HandlerFunc is the signature for a builtin command implementation. +type HandlerFunc func(ctx context.Context, callCtx *CallContext, args []string) Result + +// CallContext provides the capabilities available to builtin commands. +// It is created by the Runner for each builtin invocation. +type CallContext struct { + Stdout io.Writer + Stderr io.Writer + Stdin *os.File + + // InLoop is true when the builtin runs inside a for loop. + InLoop bool + + // LastExitCode is the exit code from the previous command. + LastExitCode uint8 + + // OpenFile opens a file within the shell's path restrictions. + OpenFile func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) +} + +// Out writes a string to stdout. +func (c *CallContext) Out(s string) { + io.WriteString(c.Stdout, s) +} + +// Outf writes a formatted string to stdout. +func (c *CallContext) Outf(format string, a ...any) { + fmt.Fprintf(c.Stdout, format, a...) +} + +// Errf writes a formatted string to stderr. +func (c *CallContext) Errf(format string, a ...any) { + fmt.Fprintf(c.Stderr, format, a...) +} + +// Result captures the outcome of executing a builtin command. +type Result struct { + // Code is the exit status code. + Code uint8 + + // Exiting signals that the shell should exit (set by the "exit" builtin). + Exiting bool + + // BreakN > 0 means break out of N enclosing loops. + BreakN int + + // ContinueN > 0 means continue from N enclosing loops. + ContinueN int +} + +var registry = map[string]HandlerFunc{ + "true": builtinTrue, + "false": builtinFalse, + "echo": builtinEcho, + "cat": builtinCat, + "exit": builtinExit, + "break": builtinBreak, + "continue": builtinContinue, +} + +// Lookup returns the handler for a builtin command. +func Lookup(name string) (HandlerFunc, bool) { + fn, ok := registry[name] + return fn, ok +} + diff --git a/interp/builtins/cat.go b/interp/builtins/cat.go new file mode 100644 index 00000000..dde68dd5 --- /dev/null +++ b/interp/builtins/cat.go @@ -0,0 +1,46 @@ +// Copyright (c) Datadog, Inc. +// See LICENSE for licensing information + +package builtins + +import ( + "context" + "io" + "os" +) + +func builtinCat(ctx context.Context, callCtx *CallContext, args []string) Result { + if len(args) == 0 { + args = []string{"-"} + } + var failed bool + for _, arg := range args { + if err := catFile(ctx, callCtx, arg); err != nil { + callCtx.Errf("cat: %s: %v\n", arg, err) + failed = true + } + } + if failed { + return Result{Code: 1} + } + return Result{} +} + +func catFile(ctx context.Context, callCtx *CallContext, path string) error { + var rc io.ReadCloser + if path == "-" { + if callCtx.Stdin == nil { + return nil + } + rc = io.NopCloser(callCtx.Stdin) + } else { + f, err := callCtx.OpenFile(ctx, path, os.O_RDONLY, 0) + if err != nil { + return err + } + rc = f + } + defer rc.Close() + _, err := io.Copy(callCtx.Stdout, rc) + return err +} diff --git a/interp/builtins/echo.go b/interp/builtins/echo.go new file mode 100644 index 00000000..afdb9681 --- /dev/null +++ b/interp/builtins/echo.go @@ -0,0 +1,17 @@ +// Copyright (c) Datadog, Inc. +// See LICENSE for licensing information + +package builtins + +import "context" + +func builtinEcho(_ context.Context, callCtx *CallContext, args []string) Result { + for i, arg := range args { + if i > 0 { + callCtx.Out(" ") + } + callCtx.Out(arg) + } + callCtx.Out("\n") + return Result{} +} diff --git a/interp/builtins/exit.go b/interp/builtins/exit.go new file mode 100644 index 00000000..4feeff27 --- /dev/null +++ b/interp/builtins/exit.go @@ -0,0 +1,38 @@ +// Copyright (c) Datadog, Inc. +// See LICENSE for licensing information + +package builtins + +import ( + "context" + "strconv" +) + +func builtinExit(_ context.Context, callCtx *CallContext, args []string) Result { + var r Result + if len(args) > 0 && args[0] == "--" { + args = args[1:] + } + switch len(args) { + case 0: + r.Code = callCtx.LastExitCode + case 1: + n, err := strconv.Atoi(args[0]) + if err != nil { + callCtx.Errf("invalid exit status code: %q\n", args[0]) + r.Code = 2 + // In bash, exit with invalid args still terminates the shell. + r.Exiting = true + return r + } + r.Code = uint8(n) + default: + callCtx.Errf("exit cannot take multiple arguments\n") + r.Code = 1 + // In bash, exit with too many args still terminates the shell. + r.Exiting = true + return r + } + r.Exiting = true + return r +} diff --git a/interp/builtins/true_false.go b/interp/builtins/true_false.go new file mode 100644 index 00000000..fa02429c --- /dev/null +++ b/interp/builtins/true_false.go @@ -0,0 +1,14 @@ +// Copyright (c) Datadog, Inc. +// See LICENSE for licensing information + +package builtins + +import "context" + +func builtinTrue(_ context.Context, _ *CallContext, _ []string) Result { + return Result{} +} + +func builtinFalse(_ context.Context, _ *CallContext, _ []string) Result { + return Result{Code: 1} +} diff --git a/interp/handler.go b/interp/handler.go new file mode 100644 index 00000000..80a42a1d --- /dev/null +++ b/interp/handler.go @@ -0,0 +1,104 @@ +// Copyright (c) 2017, Daniel Martí +// See LICENSE for licensing information + +package interp + +import ( + "context" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" +) + +// HandlerCtx returns HandlerContext value stored in ctx. +// It panics if ctx has no HandlerContext stored. +func HandlerCtx(ctx context.Context) HandlerContext { + hc, ok := ctx.Value(handlerCtxKey{}).(HandlerContext) + if !ok { + panic("interp.HandlerCtx: no HandlerContext in ctx") + } + return hc +} + +type handlerCtxKey struct{} + +// HandlerContext is the data passed to all the handler functions via [context.WithValue]. +// It contains some of the current state of the [Runner]. +type HandlerContext struct { + // Env is a read-only version of the interpreter's environment, + // including environment variables and global variables. + Env expand.Environ + + // Dir is the interpreter's current directory. + Dir string + + // Pos is the source position which relates to the operation, + // such as a [syntax.CallExpr] when calling an [ExecHandlerFunc]. + // It may be invalid if the operation has no relevant position information. + Pos syntax.Pos + + // Stdin is the interpreter's current standard input reader. + // It is always an [*os.File], but the type here remains an [io.Reader] + // due to backwards compatibility. + Stdin io.Reader + // Stdout is the interpreter's current standard output writer. + Stdout io.Writer + // Stderr is the interpreter's current standard error writer. + Stderr io.Writer +} + +// OpenHandlerFunc is a handler which opens files. +// It is called for all files that are opened directly by the shell, +// such as in redirects. +// Files opened by executed programs are not included. +// +// The path parameter may be relative to the current directory, +// which can be fetched via [HandlerCtx]. +// +// Use a return error of type [*os.PathError] to have the error printed to +// stderr and the exit status set to 1. +// Any other error will halt the [Runner] and will be returned via the API. +// +// Note that implementations which do not return [os.File] will cause +// extra files and goroutines for input redirections; see [StdIO]. +type OpenHandlerFunc func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) + +// defaultOpenHandler returns the [OpenHandlerFunc] used by default. +// It uses [os.OpenFile] to open files. +// +// On Windows, /dev/null is transparently mapped to NUL (the Windows +// equivalent) so that shell scripts using /dev/null work cross-platform. +func defaultOpenHandler() OpenHandlerFunc { + return func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { + mc := HandlerCtx(ctx) + if runtime.GOOS == "windows" && path == "/dev/null" { + // /dev/null is always safe to open: it returns EOF on read + // and discards writes. Map it to NUL, the Windows equivalent. + path = "NUL" + // Work around https://go.dev/issue/71752, where Go 1.24 started giving + // "Invalid handle" errors when opening "NUL" with O_TRUNC. + // TODO: hopefully remove this in the future once the bug is fixed. + flag &^= os.O_TRUNC + } else if path != "" && !filepath.IsAbs(path) { + path = filepath.Join(mc.Dir, path) + } + return os.OpenFile(path, flag, perm) + } +} + +// ReadDirHandlerFunc is a handler which reads directories. It is called during +// shell globbing, if enabled. +type ReadDirHandlerFunc func(ctx context.Context, path string) ([]fs.DirEntry, error) + +// defaultReadDirHandler returns the [ReadDirHandlerFunc] used by default. +// It uses [os.ReadDir]. +func defaultReadDirHandler() ReadDirHandlerFunc { + return func(ctx context.Context, path string) ([]fs.DirEntry, error) { + return os.ReadDir(path) + } +} diff --git a/interp/handler_exec.go b/interp/handler_exec.go new file mode 100644 index 00000000..31a5aeda --- /dev/null +++ b/interp/handler_exec.go @@ -0,0 +1,103 @@ +// Copyright (c) 2017, Daniel Martí +// See LICENSE for licensing information + +package interp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "mvdan.cc/sh/v3/expand" +) + +// ExecHandlerFunc is a handler which executes simple commands. +// It is called for all [syntax.CallExpr] nodes +// where the first argument is not a builtin. +// +// Returning a nil error means a zero exit status. +// Other exit statuses can be set by returning or wrapping an [ExitStatus] error, +// and such an error is returned via the API if it is the last statement executed. +// Any other error will halt the [Runner] and will be returned via the API. +type ExecHandlerFunc func(ctx context.Context, args []string) error + +// noExecHandler returns an [ExecHandlerFunc] that rejects all commands. +// It prints ": command not found" to stderr and returns exit code 127, +// without ever searching PATH or executing host binaries. +func noExecHandler() ExecHandlerFunc { + return func(ctx context.Context, args []string) error { + hc := HandlerCtx(ctx) + fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0]) + return ExitStatus(127) + } +} + +func checkStat(dir, file string, checkExec bool) (string, error) { + if !filepath.IsAbs(file) { + file = filepath.Join(dir, file) + } + info, err := os.Stat(file) + if err != nil { + return "", err + } + m := info.Mode() + if m.IsDir() { + return "", fmt.Errorf("is a directory") + } + if checkExec && runtime.GOOS != "windows" && m&0o111 == 0 { + return "", fmt.Errorf("permission denied") + } + return file, nil +} + +// findExecutable returns the path to an existing executable file. +func findExecutable(dir, file string) (string, error) { + return checkStat(dir, file, true) +} + +// ExecLookPathDir is similar to [os/exec.LookPath], with the difference that it uses the +// provided environment. env is used to fetch relevant environment variables +// such as PWD and PATH. +// +// If no error is returned, the returned path must be valid. +func ExecLookPathDir(cwd string, env expand.Environ, file string) (string, error) { + return lookPathDir(cwd, env, file, findExecutable) +} + +// findAny defines a function to pass to [lookPathDir]. +type findAny = func(dir string, file string) (string, error) + +func lookPathDir(cwd string, env expand.Environ, file string, find findAny) (string, error) { + if find == nil { + panic("no find function found") + } + + pathList := filepath.SplitList(env.Get("PATH").String()) + if len(pathList) == 0 { + pathList = []string{""} + } + chars := `/` + if runtime.GOOS == "windows" { + chars = `:\/` + } + if strings.ContainsAny(file, chars) { + return find(cwd, file) + } + for _, elem := range pathList { + var path string + switch elem { + case "", ".": + // otherwise "foo" won't be "./foo" + path = "." + string(filepath.Separator) + file + default: + path = filepath.Join(elem, file) + } + if f, err := find(cwd, path); err == nil { + return f, nil + } + } + return "", fmt.Errorf("%q: executable file not found in $PATH", file) +} diff --git a/interp/runner.go b/interp/runner.go new file mode 100644 index 00000000..7881da41 --- /dev/null +++ b/interp/runner.go @@ -0,0 +1,488 @@ +// Copyright (c) 2017, Daniel Martí +// See LICENSE for licensing information + +package interp + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "strings" + "sync" + + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/datadog-agent/pkg/shell/interp/builtins" +) + +func (r *Runner) fillExpandConfig(ctx context.Context) { + r.ectx = ctx + r.ecfg = &expand.Config{ + Env: expandEnv{r}, + // CmdSubst is intentionally nil: command substitution is blocked + // at the AST validation level, and a nil handler causes the expand + // package to return UnexpectedCommandError as defense in depth. + } + r.updateExpandOpts() +} + +func (r *Runner) updateExpandOpts() { + r.ecfg.ReadDir2 = func(s string) ([]fs.DirEntry, error) { + return r.readDirHandler(r.handlerCtx(r.ectx, todoPos), s) + } +} + +func (r *Runner) expandErr(err error) { + if err == nil { + return + } + errMsg := err.Error() + fmt.Fprintln(r.stderr, errMsg) + switch { + case errors.As(err, &expand.UnsetParameterError{}): + case errMsg == "invalid indirect expansion": + // TODO: These errors are treated as fatal by bash. + // Make the error type reflect that. + case strings.HasSuffix(errMsg, "not supported"): + // TODO: This "has suffix" is a temporary measure until the expand + // package supports all syntax nodes like extended globbing. + default: + return // other cases do not exit + } + r.exit.code = 1 + r.exit.exiting = true +} + +func (r *Runner) fields(words ...*syntax.Word) []string { + strs, err := expand.Fields(r.ecfg, words...) + r.expandErr(err) + return strs +} + +func (r *Runner) literal(word *syntax.Word) string { + str, err := expand.Literal(r.ecfg, word) + r.expandErr(err) + return str +} + +func (r *Runner) document(word *syntax.Word) string { + str, err := expand.Document(r.ecfg, word) + r.expandErr(err) + return str +} + +// expandEnviron exposes [Runner]'s variables to the expand package. +type expandEnv struct { + r *Runner +} + +var _ expand.WriteEnviron = expandEnv{} + +func (e expandEnv) Get(name string) expand.Variable { + return e.r.lookupVar(name) +} + +func (e expandEnv) Set(name string, vr expand.Variable) error { + e.r.setVar(name, vr) + return nil // TODO: return any errors +} + +func (e expandEnv) Each(fn func(name string, vr expand.Variable) bool) { + e.r.writeEnv.Each(fn) +} + +var todoPos syntax.Pos // for handlerCtx callers where we don't yet have a position + +func (r *Runner) handlerCtx(ctx context.Context, pos syntax.Pos) context.Context { + hc := HandlerContext{ + Env: &overlayEnviron{parent: r.writeEnv}, + Dir: r.Dir, + Pos: pos, + Stdout: r.stdout, + Stderr: r.stderr, + } + if r.stdin != nil { // do not leave hc.Stdin as a typed nil + hc.Stdin = r.stdin + } + return context.WithValue(ctx, handlerCtxKey{}, hc) +} + +func (r *Runner) errf(format string, a ...any) { + fmt.Fprintf(r.stderr, format, a...) +} + +func (r *Runner) stop(ctx context.Context) bool { + if r.exit.exiting { + return true + } + if err := ctx.Err(); err != nil { + r.exit.fatal(err) + return true + } + return false +} + +func (r *Runner) stmt(ctx context.Context, st *syntax.Stmt) { + if r.stop(ctx) { + return + } + r.exit = exitStatus{} + r.stmtSync(ctx, st) + r.lastExit = r.exit +} + +func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { + oldIn, oldOut, oldErr := r.stdin, r.stdout, r.stderr + for _, rd := range st.Redirs { + cls, err := r.redir(ctx, rd) + if err != nil { + r.exit.code = 1 + break + } + if cls != nil { + defer cls.Close() + } + } + if r.exit.ok() && st.Cmd != nil { + r.cmd(ctx, st.Cmd) + } + if st.Negated && !r.exit.exiting { + wasOk := r.exit.ok() + r.exit = exitStatus{} + r.exit.oneIf(wasOk) + } + r.stdin, r.stdout, r.stderr = oldIn, oldOut, oldErr +} + +func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { + if r.stop(ctx) { + return + } + + switch cm := cm.(type) { + case *syntax.Block: + r.stmts(ctx, cm.Stmts) + case *syntax.CallExpr: + args := cm.Args + r.lastExpandExit = exitStatus{} + fields := r.fields(args...) + if len(fields) == 0 { + for _, as := range cm.Assigns { + prev := r.lookupVar(as.Name.Value) + prev.Local = false + + vr := r.assignVal(prev, as, "") + r.setVarWithIndex(prev, as.Name.Value, as.Index, vr) + } + // If interpreting the last expansion like $(foo) failed, + // and the expansion and assignments otherwise succeeded, + // we need to surface that last exit code. + if r.exit.ok() { + r.exit = r.lastExpandExit + } + break + } + + type restoreVar struct { + name string + vr expand.Variable + } + var restores []restoreVar + + for _, as := range cm.Assigns { + name := as.Name.Value + prev := r.lookupVar(name) + + vr := r.assignVal(prev, as, "") + // Inline command vars are always exported. + vr.Exported = true + + restores = append(restores, restoreVar{name, prev}) + + r.setVar(name, vr) + } + + r.call(ctx, cm.Args[0].Pos(), fields) + for _, restore := range restores { + r.setVar(restore.name, restore.vr) + } + case *syntax.BinaryCmd: + switch cm.Op { + case syntax.AndStmt, syntax.OrStmt: + r.stmt(ctx, cm.X) + if r.breakEnclosing > 0 || r.contnEnclosing > 0 || r.exit.exiting { + break + } + if r.exit.ok() == (cm.Op == syntax.AndStmt) { + r.stmt(ctx, cm.Y) + } + case syntax.Pipe: + pr, pw, err := os.Pipe() + if err != nil { + r.exit.fatal(err) // not being able to create a pipe is rare but critical + return + } + rLeft := r.subshell(true) + rLeft.stdout = pw + rLeft.stderr = r.stderr + rRight := r.subshell(true) + rRight.stdin = pr + var wg sync.WaitGroup + wg.Add(1) + go func() { + rLeft.stmt(ctx, cm.X) + rLeft.exit.exiting = false + pw.Close() + wg.Done() + }() + rRight.stmt(ctx, cm.Y) + r.exit = rRight.exit + r.exit.exiting = false + pr.Close() + wg.Wait() + if rLeft.exit.fatalExit { + r.exit.fatal(rLeft.exit.err) + } + } + case *syntax.ForClause: + switch y := cm.Loop.(type) { + case *syntax.WordIter: + name := y.Name.Value + items := r.Params // for i; do ... + + inToken := y.InPos.IsValid() + if inToken { + items = r.fields(y.Items...) // for i in ...; do ... + } + + for _, field := range items { + r.setVarString(name, field) + if r.loopStmtsBroken(ctx, cm.Do) { + break + } + } + default: + r.exit.fatal(fmt.Errorf("unsupported loop type: %T", cm.Loop)) + } + default: + r.exit.fatal(fmt.Errorf("unsupported command node: %T", cm)) + } +} + +func (r *Runner) stmts(ctx context.Context, stmts []*syntax.Stmt) { + for _, stmt := range stmts { + r.stmt(ctx, stmt) + } +} + +// isQuotedHdoc reports whether the heredoc delimiter contains any quoting. +// Per POSIX, if any part of the delimiter is quoted, the heredoc body +// must not undergo expansion or backslash processing. +func isQuotedHdoc(rd *syntax.Redirect) bool { + for _, part := range rd.Word.Parts { + switch p := part.(type) { + case *syntax.SglQuoted, *syntax.DblQuoted: + return true + case *syntax.Lit: + if strings.ContainsRune(p.Value, '\\') { + return true + } + } + } + return false +} + +// hdocLiteral reconstructs the literal (unexpanded) text of a heredoc body. +// This is used for quoted delimiters where no expansion should occur. +func hdocLiteral(word *syntax.Word) string { + var buf strings.Builder + for _, part := range word.Parts { + hdocLiteralPart(&buf, part) + } + return buf.String() +} + +func hdocLiteralPart(buf *strings.Builder, part syntax.WordPart) { + switch x := part.(type) { + case *syntax.Lit: + buf.WriteString(x.Value) + case *syntax.ParamExp: + buf.WriteByte('$') + if !x.Short { + buf.WriteByte('{') + buf.WriteString(x.Param.Value) + buf.WriteByte('}') + } else { + buf.WriteString(x.Param.Value) + } + case *syntax.SglQuoted: + buf.WriteString(x.Value) + case *syntax.DblQuoted: + for _, p := range x.Parts { + hdocLiteralPart(buf, p) + } + } +} + +func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) { + pr, pw, err := os.Pipe() + if err != nil { + return nil, err + } + // We write to the pipe in a new goroutine, + // as pipe writes may block once the buffer gets full. + // We still construct and buffer the entire heredoc first, + // as doing it concurrently would lead to different semantics and be racy. + quoted := isQuotedHdoc(rd) + expandWord := func(w *syntax.Word) string { + if quoted { + return hdocLiteral(w) + } + return r.document(w) + } + if rd.Op != syntax.DashHdoc { + hdoc := expandWord(rd.Hdoc) + go func() { + pw.WriteString(hdoc) + pw.Close() + }() + return pr, nil + } + var buf bytes.Buffer + var cur []syntax.WordPart + flushLine := func() { + if buf.Len() > 0 { + buf.WriteByte('\n') + } + buf.WriteString(expandWord(&syntax.Word{Parts: cur})) + cur = cur[:0] + } + for _, wp := range rd.Hdoc.Parts { + lit, ok := wp.(*syntax.Lit) + if !ok { + cur = append(cur, wp) + continue + } + for i, part := range strings.Split(lit.Value, "\n") { + if i > 0 { + flushLine() + cur = cur[:0] + } + part = strings.TrimLeft(part, "\t") + cur = append(cur, &syntax.Lit{Value: part}) + } + } + flushLine() + go func() { + pw.Write(buf.Bytes()) + pw.Close() + }() + return pr, nil +} + +func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { + if rd.Hdoc != nil { + pr, err := r.hdocReader(rd) + if err != nil { + return nil, err + } + r.stdin = pr + return pr, nil + } + if rd.Op == syntax.Hdoc || rd.Op == syntax.DashHdoc { + pr, pw, err := os.Pipe() + if err != nil { + return nil, err + } + go func() { pw.Close() }() + r.stdin = pr + return pr, nil + } + + arg := r.literal(rd.Word) + switch rd.Op { + case syntax.RdrIn: + // done further below + default: + return nil, fmt.Errorf("unhandled redirect op: %v", rd.Op) + } + f, err := r.open(ctx, arg, os.O_RDONLY, 0, true) + if err != nil { + return nil, err + } + stdin, err := stdinFile(f) + if err != nil { + return nil, err + } + r.stdin = stdin + return f, nil +} + +func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool { + oldInLoop := r.inLoop + r.inLoop = true + defer func() { r.inLoop = oldInLoop }() + for _, stmt := range stmts { + r.stmt(ctx, stmt) + if r.contnEnclosing > 0 { + r.contnEnclosing-- + return r.contnEnclosing > 0 + } + if r.breakEnclosing > 0 { + r.breakEnclosing-- + return true + } + } + return false +} + +func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { + if r.stop(ctx) { + return + } + name := args[0] + if fn, ok := builtins.Lookup(name); ok { + call := &builtins.CallContext{ + Stdout: r.stdout, + Stderr: r.stderr, + Stdin: r.stdin, + InLoop: r.inLoop, + LastExitCode: r.lastExit.code, + OpenFile: func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { + return r.open(ctx, path, flags, mode, false) + }, + } + result := fn(ctx, call, args[1:]) + r.exit.code = result.Code + r.exit.exiting = result.Exiting + r.breakEnclosing = result.BreakN + r.contnEnclosing = result.ContinueN + return + } + r.exec(ctx, pos, args) +} + +func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { + r.exit.fromHandlerError(r.execHandler(r.handlerCtx(ctx, pos), args)) +} + +func (r *Runner) open(ctx context.Context, path string, flags int, mode os.FileMode, print bool) (io.ReadWriteCloser, error) { + f, err := r.openHandler(r.handlerCtx(ctx, todoPos), path, flags, mode) + // TODO: support wrapped PathError returned from openHandler. + switch err.(type) { + case nil: + return f, nil + case *os.PathError: + if print { + r.errf("%v\n", err) + } + default: // handler's custom fatal error + r.exit.fatal(err) + } + return nil, err +} diff --git a/interp/validate.go b/interp/validate.go new file mode 100644 index 00000000..9a5e9232 --- /dev/null +++ b/interp/validate.go @@ -0,0 +1,196 @@ +// Copyright (c) Datadog, Inc. +// See LICENSE for licensing information + +package interp + +import ( + "fmt" + + "mvdan.cc/sh/v3/syntax" +) + +// validateNode walks the AST and rejects shell constructs that are not +// supported in the safe-shell interpreter. It is called before execution +// so that disallowed features are caught early with a clear error message. +func validateNode(node syntax.Node) error { + var err error + syntax.Walk(node, func(n syntax.Node) bool { + if err != nil { + return false + } + switch n := n.(type) { + // Blocked expression-level nodes. + case *syntax.ArithmExp: + err = fmt.Errorf("arithmetic expansion is not supported") + return false + case *syntax.CmdSubst: + err = fmt.Errorf("command substitution is not supported") + return false + case *syntax.ProcSubst: + err = fmt.Errorf("process substitution is not supported") + return false + case *syntax.ParamExp: + err = validateParamExp(n) + if err != nil { + return false + } + case *syntax.Assign: + err = validateAssign(n) + if err != nil { + return false + } + + // Blocked command-level nodes. + case *syntax.IfClause: + err = fmt.Errorf("if statements are not supported") + return false + case *syntax.WhileClause: + err = fmt.Errorf("while/until loops are not supported") + return false + case *syntax.CaseClause: + err = fmt.Errorf("case statements are not supported") + return false + case *syntax.Subshell: + err = fmt.Errorf("subshells are not supported") + return false + case *syntax.FuncDecl: + err = fmt.Errorf("function declarations are not supported") + return false + case *syntax.ArithmCmd: + err = fmt.Errorf("arithmetic commands are not supported") + return false + case *syntax.TestClause: + err = fmt.Errorf("test expressions are not supported") + return false + case *syntax.DeclClause: + err = fmt.Errorf("%s is not supported", n.Variant.Value) + return false + case *syntax.LetClause: + err = fmt.Errorf("let is not supported") + return false + case *syntax.TimeClause: + err = fmt.Errorf("time is not supported") + return false + case *syntax.CoprocClause: + err = fmt.Errorf("coprocesses are not supported") + return false + case *syntax.TestDecl: + err = fmt.Errorf("test declarations are not supported") + return false + case *syntax.ForClause: + if n.Select { + err = fmt.Errorf("select statements are not supported") + return false + } + if _, ok := n.Loop.(*syntax.WordIter); !ok { + err = fmt.Errorf("c-style for loops are not supported") + return false + } + case *syntax.ExtGlob: + err = fmt.Errorf("extended globbing is not supported") + return false + + // Blocked statement-level features. + case *syntax.Stmt: + if n.Background { + err = fmt.Errorf("background execution (&) is not supported") + return false + } + + // Blocked pipe operators. + case *syntax.BinaryCmd: + if n.Op == syntax.PipeAll { + err = fmt.Errorf("|& is not supported") + return false + } + + // Blocked redirections. + case *syntax.Redirect: + err = validateRedirect(n) + if err != nil { + return false + } + } + return true + }) + return err +} + +// blockedSpecialParams are single-character parameter names that are not +// supported in the safe-shell interpreter (positional params, $#, $0, $@, $*). +var blockedSpecialParams = map[string]bool{ + "#": true, // $# - number of positional parameters + "!": true, // $! - PID of the last background command + "0": true, // $0 - name of the shell or script + "1": true, "2": true, "3": true, "4": true, // $1-$9 - positional parameters + "5": true, "6": true, "7": true, "8": true, "9": true, + "@": true, // $@ - all positional parameters as separate words + "*": true, // $* - all positional parameters as a single word +} + +func validateParamExp(pe *syntax.ParamExp) error { + if pe.Length { + return fmt.Errorf("${#var} is not supported") + } + if pe.Slice != nil { + return fmt.Errorf("${var:offset} is not supported") + } + if pe.Repl != nil { + return fmt.Errorf("${var/pattern/replacement} is not supported") + } + if pe.Excl { + return fmt.Errorf("${!var} is not supported") + } + if pe.Index != nil { + return fmt.Errorf("array indexing is not supported") + } + if pe.Names != 0 { + return fmt.Errorf("${!prefix*} is not supported") + } + if pe.Exp != nil { + return fmt.Errorf("${var} operations (defaults, pattern removal, case conversion) are not supported") + } + // Block special parameters like $#, $0, $1-$9, $@, $* + if pe.Param != nil && blockedSpecialParams[pe.Param.Value] { + return fmt.Errorf("$%s is not supported", pe.Param.Value) + } + if pe.Param != nil && pe.Param.Value == "LINENO" { + return fmt.Errorf("$LINENO is not supported") + } + return nil +} + +func validateAssign(as *syntax.Assign) error { + if as.Append { + return fmt.Errorf("+= is not supported") + } + if as.Array != nil { + return fmt.Errorf("array assignment is not supported") + } + if as.Index != nil { + return fmt.Errorf("array index assignment is not supported") + } + return nil +} + +func validateRedirect(rd *syntax.Redirect) error { + switch rd.Op { + case syntax.WordHdoc: + return fmt.Errorf("<<< (herestring) is not supported") + case syntax.RdrOut, syntax.ClbOut: + return fmt.Errorf("> file redirection is not supported") + case syntax.AppOut: + return fmt.Errorf(">> file redirection is not supported") + case syntax.RdrAll: + return fmt.Errorf("&> file redirection is not supported") + case syntax.AppAll: + return fmt.Errorf("&>> file redirection is not supported") + case syntax.RdrInOut: + return fmt.Errorf("<> file redirection is not supported") + case syntax.DplOut: + return fmt.Errorf(">&N fd duplication is not supported") + case syntax.DplIn: + return fmt.Errorf("<&N fd duplication is not supported") + } + return nil +} diff --git a/interp/vars.go b/interp/vars.go new file mode 100644 index 00000000..ee856e04 --- /dev/null +++ b/interp/vars.go @@ -0,0 +1,162 @@ +// Copyright (c) 2017, Daniel Martí +// See LICENSE for licensing information + +package interp + +import ( + "fmt" + "maps" + "runtime" + "strconv" + "strings" + + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" +) + +func newOverlayEnviron(parent expand.Environ, background bool) *overlayEnviron { + oenv := &overlayEnviron{} + if !background { + oenv.parent = parent + } else { + // We could do better here if the parent is also an overlayEnviron; + // measure with profiles or benchmarks before we choose to do so. + oenv.values = make(map[string]expand.Variable) + maps.Insert(oenv.values, parent.Each) + } + return oenv +} + +// overlayEnviron is our main implementation of [expand.WriteEnviron]. +type overlayEnviron struct { + // parent is non-nil if [values] is an overlay over a parent environment + // which we can safely reuse without data races, such as non-background subshells. + parent expand.Environ + values map[string]expand.Variable +} + +func (o *overlayEnviron) Get(name string) expand.Variable { + if vr, ok := o.values[name]; ok { + return vr + } + if o.parent != nil { + return o.parent.Get(name) + } + return expand.Variable{} +} + +func (o *overlayEnviron) Set(name string, vr expand.Variable) error { + prev, inOverlay := o.values[name] + if !inOverlay && o.parent != nil { + prev = o.parent.Get(name) + } + + if o.values == nil { + o.values = make(map[string]expand.Variable) + } + if vr.Kind == expand.KeepValue { + vr.Kind = prev.Kind + vr.Str = prev.Str + vr.List = prev.List + vr.Map = prev.Map + } else if prev.ReadOnly { + return fmt.Errorf("readonly variable") + } + if !vr.IsSet() { // unsetting + if prev.Local { + vr.Local = true + o.values[name] = vr + return nil + } + delete(o.values, name) + return nil + } + // modifying the entire variable + vr.Local = prev.Local || vr.Local + o.values[name] = vr + return nil +} + +func (o *overlayEnviron) Each(f func(name string, vr expand.Variable) bool) { + if o.parent != nil { + o.parent.Each(f) + } + for name, vr := range o.values { + if !f(name, vr) { + return + } + } +} + +func (r *Runner) lookupVar(name string) expand.Variable { + if name == "" { + panic("variable name must not be empty") + } + // Only $? is supported as a special variable in safe-shell. + if name == "?" { + return expand.Variable{ + Set: true, + Kind: expand.String, + Str: strconv.Itoa(int(r.lastExit.code)), + } + } + if vr := r.writeEnv.Get(name); vr.Declared() { + return vr + } + if runtime.GOOS == "windows" { + upper := strings.ToUpper(name) + if vr := r.writeEnv.Get(upper); vr.Declared() { + return vr + } + } + return expand.Variable{} +} + +func (r *Runner) setVarString(name, value string) { + r.setVar(name, expand.Variable{Set: true, Kind: expand.String, Str: value}) +} + +func (r *Runner) setVar(name string, vr expand.Variable) { + if err := r.writeEnv.Set(name, vr); err != nil { + r.errf("%s: %v\n", name, err) + r.exit.code = 1 + return + } +} + +// setVarWithIndex sets a variable. In safe-shell, arrays and indexing are +// blocked by the AST validator, so we only handle simple string assignment. +func (r *Runner) setVarWithIndex(prev expand.Variable, name string, index syntax.ArithmExpr, vr expand.Variable) { + if index != nil { + panic("setVarWithIndex: index should have been rejected by AST validation") + } + prev.Set = true + if name2, var2 := prev.Resolve(r.writeEnv); name2 != "" { + name = name2 + prev = var2 + } + r.setVar(name, vr) +} + +// assignVal evaluates the value of an assignment. In safe-shell, only simple +// string assignments are supported (no append, no arrays, no NameRef). The AST +// validator rejects those constructs before we get here, so hitting them is a +// programming error. +func (r *Runner) assignVal(prev expand.Variable, as *syntax.Assign, _ string) expand.Variable { + prev.Set = true + if as.Append { + panic("assignVal: append should have been rejected by AST validation") + } + if as.Array != nil { + panic("assignVal: array assignment should have been rejected by AST validation") + } + if as.Value != nil { + prev.Kind = expand.String + prev.Str = r.literal(as.Value) + return prev + } + // Bare assignment (e.g. VAR=) + prev.Kind = expand.String + prev.Str = "" + return prev +} diff --git a/tests/scenarios/cmd/cat/basic/concat_order.yaml b/tests/scenarios/cmd/cat/basic/concat_order.yaml new file mode 100644 index 00000000..49e0bb3b --- /dev/null +++ b/tests/scenarios/cmd/cat/basic/concat_order.yaml @@ -0,0 +1,26 @@ +description: Cat concatenates files in the order specified, preserving content order. +setup: + files: + - path: first.txt + content: |+ + AAA + chmod: 0644 + - path: second.txt + content: |+ + BBB + chmod: 0644 + - path: third.txt + content: |+ + CCC + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + cat third.txt first.txt second.txt +expect: + stdout: |+ + CCC + AAA + BBB + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/basic/dash_no_stdin.yaml b/tests/scenarios/cmd/cat/basic/dash_no_stdin.yaml new file mode 100644 index 00000000..773ac97d --- /dev/null +++ b/tests/scenarios/cmd/cat/basic/dash_no_stdin.yaml @@ -0,0 +1,8 @@ +description: Explicit stdin reference (cat -) with no stdin connected exits successfully with no output. +input: + script: |+ + cat - +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/cat/basic/dash_with_heredoc.yaml b/tests/scenarios/cmd/cat/basic/dash_with_heredoc.yaml new file mode 100644 index 00000000..a034f36c --- /dev/null +++ b/tests/scenarios/cmd/cat/basic/dash_with_heredoc.yaml @@ -0,0 +1,11 @@ +description: Cat with dash reads stdin provided by heredoc. +input: + script: |+ + cat - <>) is not supported. +input: + script: |+ + echo hello &>> output.txt +expect: + stdout: "" + stderr: |+ + &>> file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml b/tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml new file mode 100644 index 00000000..ea3444a8 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml @@ -0,0 +1,12 @@ +test_against_local_shell: false +description: Write redirect after valid commands still causes rejection. +input: + script: |+ + echo before + echo data > output.txt + echo after +expect: + stdout: "" + stderr: |+ + > file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/dup_in.yaml b/tests/scenarios/shell/blocked_redirects/dup_in.yaml new file mode 100644 index 00000000..9adb364f --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/dup_in.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Input fd duplication (<&N) is blocked. +input: + script: |+ + echo hello <&- +expect: + stdout: "" + stderr: |+ + <&N fd duplication is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/dup_out.yaml b/tests/scenarios/shell/blocked_redirects/dup_out.yaml new file mode 100644 index 00000000..977f76a1 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/dup_out.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Output fd duplication (>&N) is blocked. +input: + script: |+ + echo hello 2>&1 +expect: + stdout: "" + stderr: |+ + >&N fd duplication is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/herestring.yaml b/tests/scenarios/shell/blocked_redirects/herestring.yaml new file mode 100644 index 00000000..c159816f --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/herestring.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Herestring (<<<) is not supported (bash extension). +input: + script: |+ + cat <<< "hello" +expect: + stdout: "" + stderr: |+ + <<< (herestring) is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/read_write.yaml b/tests/scenarios/shell/blocked_redirects/read_write.yaml new file mode 100644 index 00000000..b7d083b6 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/read_write.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Read-write redirection (<>) is not supported. +input: + script: |+ + cat <> file.txt +expect: + stdout: "" + stderr: |+ + <> file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/stderr_write.yaml b/tests/scenarios/shell/blocked_redirects/stderr_write.yaml new file mode 100644 index 00000000..3328ba68 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/stderr_write.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Stderr redirection (2>) is not supported. +input: + script: |+ + echo hello 2> errors.txt +expect: + stdout: "" + stderr: |+ + > file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/write_all.yaml b/tests/scenarios/shell/blocked_redirects/write_all.yaml new file mode 100644 index 00000000..d362d0f7 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/write_all.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Redirect all (&>) is not supported. +input: + script: |+ + echo hello &> output.txt +expect: + stdout: "" + stderr: |+ + &> file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/write_append.yaml b/tests/scenarios/shell/blocked_redirects/write_append.yaml new file mode 100644 index 00000000..a58aa5a3 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/write_append.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Append redirection (>>) is not supported. +input: + script: |+ + echo hello >> output.txt +expect: + stdout: "" + stderr: |+ + >> file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/write_clobber.yaml b/tests/scenarios/shell/blocked_redirects/write_clobber.yaml new file mode 100644 index 00000000..ef681ed6 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/write_clobber.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Clobber redirection (>|) is not supported. +input: + script: |+ + echo hello >| output.txt +expect: + stdout: "" + stderr: |+ + > file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/write_truncate.yaml b/tests/scenarios/shell/blocked_redirects/write_truncate.yaml new file mode 100644 index 00000000..81d62105 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/write_truncate.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Output redirection (>) is not supported. +input: + script: |+ + echo hello > output.txt +expect: + stdout: "" + stderr: |+ + > file redirection is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/brace_group/as_and_operand.yaml b/tests/scenarios/shell/brace_group/as_and_operand.yaml new file mode 100644 index 00000000..e6a1ef14 --- /dev/null +++ b/tests/scenarios/shell/brace_group/as_and_operand.yaml @@ -0,0 +1,9 @@ +description: Brace group can be used as the right operand of an && operator. +input: + script: |+ + true && { echo "inside"; } +expect: + stdout: |+ + inside + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/as_or_operand.yaml b/tests/scenarios/shell/brace_group/as_or_operand.yaml new file mode 100644 index 00000000..da59ea5f --- /dev/null +++ b/tests/scenarios/shell/brace_group/as_or_operand.yaml @@ -0,0 +1,9 @@ +description: Brace group can be used as the right operand of an || operator. +input: + script: |+ + false || { echo "fallback"; } +expect: + stdout: |+ + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/basic.yaml b/tests/scenarios/shell/brace_group/basic.yaml new file mode 100644 index 00000000..4e6abaf6 --- /dev/null +++ b/tests/scenarios/shell/brace_group/basic.yaml @@ -0,0 +1,10 @@ +description: Brace group executes contained commands sequentially. +input: + script: |+ + { echo hello; echo world; } +expect: + stdout: |+ + hello + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/chained_with_and.yaml b/tests/scenarios/shell/brace_group/chained_with_and.yaml new file mode 100644 index 00000000..3e34b94c --- /dev/null +++ b/tests/scenarios/shell/brace_group/chained_with_and.yaml @@ -0,0 +1,10 @@ +description: Brace group exit code propagates through && chain. +input: + script: |+ + { true; } && { echo "step2"; } && { echo "step3"; } +expect: + stdout: |+ + step2 + step3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/chained_with_or.yaml b/tests/scenarios/shell/brace_group/chained_with_or.yaml new file mode 100644 index 00000000..96cffa01 --- /dev/null +++ b/tests/scenarios/shell/brace_group/chained_with_or.yaml @@ -0,0 +1,9 @@ +description: Brace group exit code propagates through || chain. +input: + script: |+ + { false; } || { echo "recovered"; } +expect: + stdout: |+ + recovered + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/deeply_nested.yaml b/tests/scenarios/shell/brace_group/deeply_nested.yaml new file mode 100644 index 00000000..94349352 --- /dev/null +++ b/tests/scenarios/shell/brace_group/deeply_nested.yaml @@ -0,0 +1,11 @@ +description: Three levels of nested brace groups execute correctly. +input: + script: |+ + { { { echo deep; }; echo mid; }; echo outer; } +expect: + stdout: |+ + deep + mid + outer + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/effect_with_exit.yaml b/tests/scenarios/shell/brace_group/effect_with_exit.yaml new file mode 100644 index 00000000..55588e19 --- /dev/null +++ b/tests/scenarios/shell/brace_group/effect_with_exit.yaml @@ -0,0 +1,11 @@ +description: Brace group with variable assignment and exit terminates execution. +input: + script: |+ + a=1 + { a=2; echo $a; exit; echo not reached; } + echo not reached +expect: + stdout: |+ + 2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/exit_code.yaml b/tests/scenarios/shell/brace_group/exit_code.yaml new file mode 100644 index 00000000..2854ed6a --- /dev/null +++ b/tests/scenarios/shell/brace_group/exit_code.yaml @@ -0,0 +1,8 @@ +description: Brace group propagates the exit code of the last command. +input: + script: |+ + { true; false; } +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/brace_group/exit_code_tracking.yaml b/tests/scenarios/shell/brace_group/exit_code_tracking.yaml new file mode 100644 index 00000000..018c8db8 --- /dev/null +++ b/tests/scenarios/shell/brace_group/exit_code_tracking.yaml @@ -0,0 +1,13 @@ +description: Exit code from brace group is captured by $? on the next line. +input: + script: |+ + { true; false; } + echo $? + { false; true; } + echo $? +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/exit_inside.yaml b/tests/scenarios/shell/brace_group/exit_inside.yaml new file mode 100644 index 00000000..2f5ed0d1 --- /dev/null +++ b/tests/scenarios/shell/brace_group/exit_inside.yaml @@ -0,0 +1,10 @@ +description: Exit inside a brace group terminates execution. +input: + script: |+ + { echo hello; exit 0; echo not reached; } + echo also not reached +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/multiple_sequential.yaml b/tests/scenarios/shell/brace_group/multiple_sequential.yaml new file mode 100644 index 00000000..3cf2dfa3 --- /dev/null +++ b/tests/scenarios/shell/brace_group/multiple_sequential.yaml @@ -0,0 +1,11 @@ +description: Multiple sequential brace groups separated by semicolons execute independently. +input: + script: |+ + { echo a; }; { echo b; }; { echo c; } +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/multiple_sequential_newlines.yaml b/tests/scenarios/shell/brace_group/multiple_sequential_newlines.yaml new file mode 100644 index 00000000..8ea3c51b --- /dev/null +++ b/tests/scenarios/shell/brace_group/multiple_sequential_newlines.yaml @@ -0,0 +1,19 @@ +description: Multiple sequential brace groups on separate lines. +input: + script: |+ + { + echo first + } + { + echo second + } + { + echo third + } +expect: + stdout: |+ + first + second + third + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/nested.yaml b/tests/scenarios/shell/brace_group/nested.yaml new file mode 100644 index 00000000..5bb20059 --- /dev/null +++ b/tests/scenarios/shell/brace_group/nested.yaml @@ -0,0 +1,10 @@ +description: Brace groups can be nested and execute correctly. +input: + script: |+ + { { echo inner; }; echo outer; } +expect: + stdout: |+ + inner + outer + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/nested_var_scope.yaml b/tests/scenarios/shell/brace_group/nested_var_scope.yaml new file mode 100644 index 00000000..de7d726d --- /dev/null +++ b/tests/scenarios/shell/brace_group/nested_var_scope.yaml @@ -0,0 +1,20 @@ +description: Variables flow through nested brace groups in shared scope. +input: + script: |+ + A=1 + { + B=2 + { + C=3 + echo "$A $B $C" + } + echo "$A $B $C" + } + echo "$A $B $C" +expect: + stdout: |+ + 1 2 3 + 1 2 3 + 1 2 3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/newlines.yaml b/tests/scenarios/shell/brace_group/newlines.yaml new file mode 100644 index 00000000..b578d5ad --- /dev/null +++ b/tests/scenarios/shell/brace_group/newlines.yaml @@ -0,0 +1,13 @@ +description: Brace group works with multi-line formatting using newlines. +input: + script: |+ + { + echo hello + echo world + } +expect: + stdout: |+ + hello + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/only_false.yaml b/tests/scenarios/shell/brace_group/only_false.yaml new file mode 100644 index 00000000..5b6908b2 --- /dev/null +++ b/tests/scenarios/shell/brace_group/only_false.yaml @@ -0,0 +1,8 @@ +description: Brace group containing only false has exit code 1. +input: + script: |+ + { false; } +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/brace_group/only_true.yaml b/tests/scenarios/shell/brace_group/only_true.yaml new file mode 100644 index 00000000..8da253f0 --- /dev/null +++ b/tests/scenarios/shell/brace_group/only_true.yaml @@ -0,0 +1,8 @@ +description: Brace group containing only true has exit code 0. +input: + script: |+ + { true; } +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/single_command.yaml b/tests/scenarios/shell/brace_group/single_command.yaml new file mode 100644 index 00000000..92f57e2a --- /dev/null +++ b/tests/scenarios/shell/brace_group/single_command.yaml @@ -0,0 +1,9 @@ +description: A brace group containing a single command executes correctly. +input: + script: |+ + { echo only; } +expect: + stdout: |+ + only + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/var_shared_scope.yaml b/tests/scenarios/shell/brace_group/var_shared_scope.yaml new file mode 100644 index 00000000..b7037665 --- /dev/null +++ b/tests/scenarios/shell/brace_group/var_shared_scope.yaml @@ -0,0 +1,12 @@ +description: Variables modified inside a brace group are visible outside (shared scope). +input: + script: |+ + V=1 + { V=2; echo inner $V; } + echo outer $V +expect: + stdout: |+ + inner 2 + outer 2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/with_and_or.yaml b/tests/scenarios/shell/brace_group/with_and_or.yaml new file mode 100644 index 00000000..f908572d --- /dev/null +++ b/tests/scenarios/shell/brace_group/with_and_or.yaml @@ -0,0 +1,10 @@ +description: And-or operators work inside brace groups. +input: + script: |+ + { true && echo "and-ok"; false || echo "or-ok"; } +expect: + stdout: |+ + and-ok + or-ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/with_for_loop.yaml b/tests/scenarios/shell/brace_group/with_for_loop.yaml new file mode 100644 index 00000000..d9a68bad --- /dev/null +++ b/tests/scenarios/shell/brace_group/with_for_loop.yaml @@ -0,0 +1,15 @@ +description: For loop can be used inside a brace group. +input: + script: |+ + { + for i in x y z; do + echo "$i" + done + } +expect: + stdout: |+ + x + y + z + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/with_logic_exit_code.yaml b/tests/scenarios/shell/brace_group/with_logic_exit_code.yaml new file mode 100644 index 00000000..5d2f6633 --- /dev/null +++ b/tests/scenarios/shell/brace_group/with_logic_exit_code.yaml @@ -0,0 +1,13 @@ +description: Logic operators inside brace group affect the group exit code. +input: + script: |+ + { true && false; } + echo $? + { false || true; } + echo $? +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/brace_group/with_pipe.yaml b/tests/scenarios/shell/brace_group/with_pipe.yaml new file mode 100644 index 00000000..51d59632 --- /dev/null +++ b/tests/scenarios/shell/brace_group/with_pipe.yaml @@ -0,0 +1,10 @@ +description: Brace group output can be piped to another command. +input: + script: |+ + { echo hello; echo world; } | cat +expect: + stdout: |+ + hello + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/empty_lines.yaml b/tests/scenarios/shell/cmd_separator/basic/empty_lines.yaml new file mode 100644 index 00000000..529e3a46 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/empty_lines.yaml @@ -0,0 +1,15 @@ +description: Empty lines between commands are ignored. +input: + script: |+ + echo first + + echo second + + echo third +expect: + stdout: |+ + first + second + third + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/long_semicolon_chain.yaml b/tests/scenarios/shell/cmd_separator/basic/long_semicolon_chain.yaml new file mode 100644 index 00000000..29b5344e --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/long_semicolon_chain.yaml @@ -0,0 +1,18 @@ +description: A long chain of ten semicolon-separated commands all execute in order. +input: + script: |+ + echo 1; echo 2; echo 3; echo 4; echo 5; echo 6; echo 7; echo 8; echo 9; echo 10 +expect: + stdout: |+ + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/mixed.yaml b/tests/scenarios/shell/cmd_separator/basic/mixed.yaml new file mode 100644 index 00000000..6c65fb11 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/mixed.yaml @@ -0,0 +1,13 @@ +description: Semicolons and newlines can be mixed freely as command separators. +input: + script: |+ + echo one; echo two + echo three; echo four +expect: + stdout: |+ + one + two + three + four + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/newline_multiple.yaml b/tests/scenarios/shell/cmd_separator/basic/newline_multiple.yaml new file mode 100644 index 00000000..68f47ac8 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/newline_multiple.yaml @@ -0,0 +1,15 @@ +description: Multiple commands separated by newlines all execute in order. +input: + script: |+ + echo one + echo two + echo three + echo four +expect: + stdout: |+ + one + two + three + four + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/newline_simple.yaml b/tests/scenarios/shell/cmd_separator/basic/newline_simple.yaml new file mode 100644 index 00000000..8b77912f --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/newline_simple.yaml @@ -0,0 +1,11 @@ +description: Two commands separated by a newline execute sequentially. +input: + script: |+ + echo first + echo second +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/only_empty_lines.yaml b/tests/scenarios/shell/cmd_separator/basic/only_empty_lines.yaml new file mode 100644 index 00000000..76d05697 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/only_empty_lines.yaml @@ -0,0 +1,9 @@ +description: A script consisting only of empty lines produces no output and exits 0. +input: + script: |+ + + +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/semicolon_multiple.yaml b/tests/scenarios/shell/cmd_separator/basic/semicolon_multiple.yaml new file mode 100644 index 00000000..3d6527a8 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/semicolon_multiple.yaml @@ -0,0 +1,12 @@ +description: Multiple commands separated by semicolons all execute in order. +input: + script: |+ + echo one; echo two; echo three; echo four +expect: + stdout: |+ + one + two + three + four + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/semicolon_simple.yaml b/tests/scenarios/shell/cmd_separator/basic/semicolon_simple.yaml new file mode 100644 index 00000000..afbe4c47 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/semicolon_simple.yaml @@ -0,0 +1,10 @@ +description: Two commands separated by a semicolon execute sequentially. +input: + script: |+ + echo first; echo second +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/trailing_semicolon.yaml b/tests/scenarios/shell/cmd_separator/basic/trailing_semicolon.yaml new file mode 100644 index 00000000..acffa1ee --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/trailing_semicolon.yaml @@ -0,0 +1,9 @@ +description: A trailing semicolon after the last command is harmless. +input: + script: |+ + echo hello; +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/basic/whitespace_around.yaml b/tests/scenarios/shell/cmd_separator/basic/whitespace_around.yaml new file mode 100644 index 00000000..d0ee434b --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/basic/whitespace_around.yaml @@ -0,0 +1,11 @@ +description: Whitespace around semicolons does not affect execution. +input: + script: |+ + echo one ; echo two ; echo three +expect: + stdout: |+ + one + two + three + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/after_for_loop.yaml b/tests/scenarios/shell/cmd_separator/control_flow/after_for_loop.yaml new file mode 100644 index 00000000..71c039fa --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/after_for_loop.yaml @@ -0,0 +1,11 @@ +description: Commands after a for loop separated by semicolons execute normally. +input: + script: |+ + for i in a b; do echo $i; done; echo after +expect: + stdout: |+ + a + b + after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/and_or_with_semicolons.yaml b/tests/scenarios/shell/cmd_separator/control_flow/and_or_with_semicolons.yaml new file mode 100644 index 00000000..8d70bda4 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/and_or_with_semicolons.yaml @@ -0,0 +1,11 @@ +description: And-or lists separated by semicolons on the same line are independent. +input: + script: |+ + true && echo a; false || echo b; true && echo c +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/braces_and_for_mixed.yaml b/tests/scenarios/shell/cmd_separator/control_flow/braces_and_for_mixed.yaml new file mode 100644 index 00000000..0ca71da0 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/braces_and_for_mixed.yaml @@ -0,0 +1,16 @@ +description: Brace groups and for loops mixed with semicolons and newlines. +input: + script: |+ + { echo a; echo b; } + for i in 1 2; do echo $i; done + { echo c; }; echo d +expect: + stdout: |+ + a + b + 1 + 2 + c + d + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/braces_then_command.yaml b/tests/scenarios/shell/cmd_separator/control_flow/braces_then_command.yaml new file mode 100644 index 00000000..112e5f74 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/braces_then_command.yaml @@ -0,0 +1,10 @@ +description: A brace group followed by a semicolon and another command. +input: + script: |+ + { echo inside; }; echo outside +expect: + stdout: |+ + inside + outside + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/for_loop_body.yaml b/tests/scenarios/shell/cmd_separator/control_flow/for_loop_body.yaml new file mode 100644 index 00000000..85bdf9fc --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/for_loop_body.yaml @@ -0,0 +1,12 @@ +description: Semicolons separate commands in a for loop body. +input: + script: |+ + for i in a b; do echo start $i; echo end $i; done +expect: + stdout: |+ + start a + end a + start b + end b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/for_loop_newline.yaml b/tests/scenarios/shell/cmd_separator/control_flow/for_loop_newline.yaml new file mode 100644 index 00000000..19ae66f9 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/for_loop_newline.yaml @@ -0,0 +1,14 @@ +description: For loop with newline-separated body and commands after. +input: + script: |+ + for i in x y; do + echo $i + done + echo after +expect: + stdout: |+ + x + y + after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/in_braces.yaml b/tests/scenarios/shell/cmd_separator/control_flow/in_braces.yaml new file mode 100644 index 00000000..c85253a6 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/in_braces.yaml @@ -0,0 +1,11 @@ +description: Semicolons separate commands inside a brace group. +input: + script: |+ + { echo one; echo two; echo three; } +expect: + stdout: |+ + one + two + three + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/multiple_for_semicolons.yaml b/tests/scenarios/shell/cmd_separator/control_flow/multiple_for_semicolons.yaml new file mode 100644 index 00000000..320e8feb --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/multiple_for_semicolons.yaml @@ -0,0 +1,12 @@ +description: Multiple for loops separated by semicolons on the same line execute sequentially. +input: + script: |+ + for i in a b; do echo $i; done; for j in 1 2; do echo $j; done +expect: + stdout: |+ + a + b + 1 + 2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/with_exit.yaml b/tests/scenarios/shell/cmd_separator/control_flow/with_exit.yaml new file mode 100644 index 00000000..bfcb12fa --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/with_exit.yaml @@ -0,0 +1,9 @@ +description: Exit stops execution; commands after exit do not run. +input: + script: |+ + echo before; exit 0; echo after +expect: + stdout: |+ + before + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/control_flow/with_exit_nonzero.yaml b/tests/scenarios/shell/cmd_separator/control_flow/with_exit_nonzero.yaml new file mode 100644 index 00000000..55060ddf --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/control_flow/with_exit_nonzero.yaml @@ -0,0 +1,9 @@ +description: Exit with non-zero code stops execution and propagates the exit code. +input: + script: |+ + echo before; exit 7; echo after +expect: + stdout: |+ + before + stderr: "" + exit_code: 7 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/continues_after_failure.yaml b/tests/scenarios/shell/cmd_separator/exit_code/continues_after_failure.yaml new file mode 100644 index 00000000..e20eb5d6 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/continues_after_failure.yaml @@ -0,0 +1,10 @@ +description: Execution continues after a failing command in a semicolon list. +input: + script: |+ + false; echo still running; echo done +expect: + stdout: |+ + still running + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/exit_code_custom.yaml b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_custom.yaml new file mode 100644 index 00000000..5cfc7bb7 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_custom.yaml @@ -0,0 +1,9 @@ +description: Custom exit code from the last command is preserved. +input: + script: |+ + echo hello; exit 42 +expect: + stdout: |+ + hello + stderr: "" + exit_code: 42 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/exit_code_first_fails.yaml b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_first_fails.yaml new file mode 100644 index 00000000..9aea23d6 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_first_fails.yaml @@ -0,0 +1,9 @@ +description: When first command fails but last succeeds, exit code is 0. +input: + script: |+ + false; echo ok +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/exit_code_last.yaml b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_last.yaml new file mode 100644 index 00000000..36d8db5e --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_last.yaml @@ -0,0 +1,9 @@ +description: Exit code of a semicolon-separated list is the exit code of the last command. +input: + script: |+ + echo hello; true +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/exit_code_last_of_many.yaml b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_last_of_many.yaml new file mode 100644 index 00000000..621b4c9e --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_last_of_many.yaml @@ -0,0 +1,8 @@ +description: Exit code of last command in a long chain is preserved. +input: + script: |+ + true; true; true; true; false +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/exit_code_middle_fails.yaml b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_middle_fails.yaml new file mode 100644 index 00000000..ca3096fb --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_middle_fails.yaml @@ -0,0 +1,10 @@ +description: Middle command failure does not affect other commands or final exit code. +input: + script: |+ + echo first; false; echo third +expect: + stdout: |+ + first + third + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/exit_code_second_fails.yaml b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_second_fails.yaml new file mode 100644 index 00000000..910979c7 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/exit_code_second_fails.yaml @@ -0,0 +1,9 @@ +description: When last command fails, the overall exit code reflects that failure. +input: + script: |+ + echo ok; false +expect: + stdout: |+ + ok + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/exit_status_chain.yaml b/tests/scenarios/shell/cmd_separator/exit_code/exit_status_chain.yaml new file mode 100644 index 00000000..6e53378e --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/exit_status_chain.yaml @@ -0,0 +1,11 @@ +description: Each command's exit status is independently tracked via $?. +input: + script: |+ + false; echo $?; true; echo $?; false; echo $? +expect: + stdout: |+ + 1 + 0 + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/newline_after_failure.yaml b/tests/scenarios/shell/cmd_separator/exit_code/newline_after_failure.yaml new file mode 100644 index 00000000..4eadbd81 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/newline_after_failure.yaml @@ -0,0 +1,12 @@ +description: Newline-separated commands continue executing after a failure. +input: + script: |+ + false + echo still running + echo done +expect: + stdout: |+ + still running + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/exit_code/unknown_command_continues.yaml b/tests/scenarios/shell/cmd_separator/exit_code/unknown_command_continues.yaml new file mode 100644 index 00000000..0633fe60 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/exit_code/unknown_command_continues.yaml @@ -0,0 +1,9 @@ +description: An unknown command does not prevent subsequent commands from running. +input: + script: |+ + nonexistent_cmd; echo after +expect: + stdout: |+ + after + stderr_contains: ["nonexistent_cmd", "command not found"] + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/var_sharing/exit_status_variable.yaml b/tests/scenarios/shell/cmd_separator/var_sharing/exit_status_variable.yaml new file mode 100644 index 00000000..0aa31b4e --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/var_sharing/exit_status_variable.yaml @@ -0,0 +1,10 @@ +description: The $? variable reflects the exit code of the immediately preceding command. +input: + script: |+ + false; echo $?; true; echo $? +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/var_sharing/var_from_brace_group.yaml b/tests/scenarios/shell/cmd_separator/var_sharing/var_from_brace_group.yaml new file mode 100644 index 00000000..db86c08c --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/var_sharing/var_from_brace_group.yaml @@ -0,0 +1,9 @@ +description: Variables set in brace groups are visible after the group. +input: + script: |+ + { X=hello; }; echo $X +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/var_sharing/var_from_for_loop.yaml b/tests/scenarios/shell/cmd_separator/var_sharing/var_from_for_loop.yaml new file mode 100644 index 00000000..367c4257 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/var_sharing/var_from_for_loop.yaml @@ -0,0 +1,9 @@ +description: Variable set inside a for loop body is visible after the loop via separator. +input: + script: |+ + for i in a b c; do LAST=$i; done; echo $LAST +expect: + stdout: |+ + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/var_sharing/variable_accumulate.yaml b/tests/scenarios/shell/cmd_separator/var_sharing/variable_accumulate.yaml new file mode 100644 index 00000000..336da469 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/var_sharing/variable_accumulate.yaml @@ -0,0 +1,10 @@ +description: Variables accumulate state across semicolons and newlines. +input: + script: |+ + a=1; b=2 + c=3; echo "$a $b $c" +expect: + stdout: |+ + 1 2 3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/var_sharing/variable_newline.yaml b/tests/scenarios/shell/cmd_separator/var_sharing/variable_newline.yaml new file mode 100644 index 00000000..e218c948 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/var_sharing/variable_newline.yaml @@ -0,0 +1,10 @@ +description: A variable set on one line is visible on the next line. +input: + script: |+ + x=hello + echo $x +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/var_sharing/variable_override.yaml b/tests/scenarios/shell/cmd_separator/var_sharing/variable_override.yaml new file mode 100644 index 00000000..257f568e --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/var_sharing/variable_override.yaml @@ -0,0 +1,10 @@ +description: A variable can be overridden across semicolon-separated commands. +input: + script: |+ + x=first; echo $x; x=second; echo $x +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/var_sharing/variable_set_then_use.yaml b/tests/scenarios/shell/cmd_separator/var_sharing/variable_set_then_use.yaml new file mode 100644 index 00000000..3727ba9a --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/var_sharing/variable_set_then_use.yaml @@ -0,0 +1,9 @@ +description: A variable set before a semicolon is visible after the semicolon. +input: + script: |+ + x=hello; echo $x +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/and_fails_then_semicolon.yaml b/tests/scenarios/shell/cmd_separator/with_ops/and_fails_then_semicolon.yaml new file mode 100644 index 00000000..dae2dd6f --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/and_fails_then_semicolon.yaml @@ -0,0 +1,9 @@ +description: Failed && skips the right side but semicolon starts a fresh statement. +input: + script: |+ + false && echo skipped; echo runs +expect: + stdout: |+ + runs + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/and_then_semicolon.yaml b/tests/scenarios/shell/cmd_separator/with_ops/and_then_semicolon.yaml new file mode 100644 index 00000000..4a210aba --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/and_then_semicolon.yaml @@ -0,0 +1,11 @@ +description: A && chain followed by a semicolon and another command. +input: + script: |+ + echo one && echo two; echo three +expect: + stdout: |+ + one + two + three + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/complex_all_features.yaml b/tests/scenarios/shell/cmd_separator/with_ops/complex_all_features.yaml new file mode 100644 index 00000000..39b6b987 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/complex_all_features.yaml @@ -0,0 +1,16 @@ +description: Complex script mixing assignments, control flow, and separators. +input: + script: |+ + A=1; B=2 + for i in $A $B; do echo "loop:$i"; done + { echo "brace:$A"; echo "brace:$B"; } + true && echo "and:ok" || echo "or:skip" +expect: + stdout: |+ + loop:1 + loop:2 + brace:1 + brace:2 + and:ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/complex_chain.yaml b/tests/scenarios/shell/cmd_separator/with_ops/complex_chain.yaml new file mode 100644 index 00000000..53caef14 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/complex_chain.yaml @@ -0,0 +1,12 @@ +description: Complex mix of semicolons, pipes, and logical operators. +input: + script: |+ + echo a | cat; echo b && echo c; false || echo d +expect: + stdout: |+ + a + b + c + d + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/multiple_lists_newlines.yaml b/tests/scenarios/shell/cmd_separator/with_ops/multiple_lists_newlines.yaml new file mode 100644 index 00000000..6cab8a49 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/multiple_lists_newlines.yaml @@ -0,0 +1,16 @@ +description: Multiple statements each with their own and-or operators separated by newlines. +input: + script: |+ + echo one && echo two + false || echo three + true && false || echo four + echo five +expect: + stdout: |+ + one + two + three + four + five + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/or_then_semicolon.yaml b/tests/scenarios/shell/cmd_separator/with_ops/or_then_semicolon.yaml new file mode 100644 index 00000000..7da01a53 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/or_then_semicolon.yaml @@ -0,0 +1,9 @@ +description: A || chain followed by a semicolon and another command. +input: + script: |+ + true || echo skipped; echo after +expect: + stdout: |+ + after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/pipe_then_semicolon.yaml b/tests/scenarios/shell/cmd_separator/with_ops/pipe_then_semicolon.yaml new file mode 100644 index 00000000..bc728e91 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/pipe_then_semicolon.yaml @@ -0,0 +1,10 @@ +description: A pipe command followed by a semicolon and another command. +input: + script: |+ + echo hello | cat; echo world +expect: + stdout: |+ + hello + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/with_and.yaml b/tests/scenarios/shell/cmd_separator/with_ops/with_and.yaml new file mode 100644 index 00000000..c56defe9 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/with_and.yaml @@ -0,0 +1,11 @@ +description: Semicolon separates independent statements; && chains within a statement. +input: + script: |+ + echo first; echo second && echo third +expect: + stdout: |+ + first + second + third + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/with_or.yaml b/tests/scenarios/shell/cmd_separator/with_ops/with_or.yaml new file mode 100644 index 00000000..f0191747 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/with_or.yaml @@ -0,0 +1,10 @@ +description: Semicolon separates independent statements; || chains within a statement. +input: + script: |+ + echo first; false || echo fallback +expect: + stdout: |+ + first + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/cmd_separator/with_ops/with_pipe.yaml b/tests/scenarios/shell/cmd_separator/with_ops/with_pipe.yaml new file mode 100644 index 00000000..9e68a0d0 --- /dev/null +++ b/tests/scenarios/shell/cmd_separator/with_ops/with_pipe.yaml @@ -0,0 +1,11 @@ +description: Semicolons can separate pipe commands from other commands. +input: + script: |+ + echo first; echo piped | cat; echo last +expect: + stdout: |+ + first + piped + last + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/after_assignment.yaml b/tests/scenarios/shell/comments/after_assignment.yaml new file mode 100644 index 00000000..cdac7fef --- /dev/null +++ b/tests/scenarios/shell/comments/after_assignment.yaml @@ -0,0 +1,10 @@ +description: Comment after variable assignment is ignored and assignment still takes effect. +input: + script: |+ + A=hello # comment after assignment + echo "$A" +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/after_semicolon.yaml b/tests/scenarios/shell/comments/after_semicolon.yaml new file mode 100644 index 00000000..359de9d2 --- /dev/null +++ b/tests/scenarios/shell/comments/after_semicolon.yaml @@ -0,0 +1,11 @@ +description: Comment after a semicolon separator is ignored. +input: + script: |+ + echo first; # comment after semicolon + echo second +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/backslash_ending.yaml b/tests/scenarios/shell/comments/backslash_ending.yaml new file mode 100644 index 00000000..9cd2e9b2 --- /dev/null +++ b/tests/scenarios/shell/comments/backslash_ending.yaml @@ -0,0 +1,10 @@ +description: A backslash at the end of a comment does not continue the line. +input: + script: |+ + # \ + echo foo +expect: + stdout: |+ + foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/backslash_ending_with_text.yaml b/tests/scenarios/shell/comments/backslash_ending_with_text.yaml new file mode 100644 index 00000000..c5c1fcda --- /dev/null +++ b/tests/scenarios/shell/comments/backslash_ending_with_text.yaml @@ -0,0 +1,10 @@ +description: Comment ending with backslash does not cause line continuation - next line runs normally. +input: + script: |+ + # this comment ends with backslash \ + echo ok +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/basic.yaml b/tests/scenarios/shell/comments/basic.yaml new file mode 100644 index 00000000..e7464127 --- /dev/null +++ b/tests/scenarios/shell/comments/basic.yaml @@ -0,0 +1,13 @@ +description: Shell comment lines are ignored and do not affect execution. +input: + script: |+ + # this is a comment + echo hello + # another comment + echo world +expect: + stdout: |+ + hello + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/comment_truncates_args.yaml b/tests/scenarios/shell/comments/comment_truncates_args.yaml new file mode 100644 index 00000000..f46c0bee --- /dev/null +++ b/tests/scenarios/shell/comments/comment_truncates_args.yaml @@ -0,0 +1,11 @@ +description: Hash after redirection operator is part of filename, not a comment. +input: + script: |+ + echo foo # everything after space-hash is comment + echo bar +expect: + stdout: |+ + foo + bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/escaped_hash_literal.yaml b/tests/scenarios/shell/comments/escaped_hash_literal.yaml new file mode 100644 index 00000000..1b2a4951 --- /dev/null +++ b/tests/scenarios/shell/comments/escaped_hash_literal.yaml @@ -0,0 +1,9 @@ +description: Escaped hash with backslash is literal, not a comment start. +input: + script: |+ + echo hello \# world +expect: + stdout: |+ + hello # world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/hash_in_quoted_string.yaml b/tests/scenarios/shell/comments/hash_in_quoted_string.yaml new file mode 100644 index 00000000..8bb3104a --- /dev/null +++ b/tests/scenarios/shell/comments/hash_in_quoted_string.yaml @@ -0,0 +1,11 @@ +description: Hash character in quoted strings is not a comment. +input: + script: |+ + echo "# not a comment" + echo '# also not' +expect: + stdout: |+ + # not a comment + # also not + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/hash_in_word.yaml b/tests/scenarios/shell/comments/hash_in_word.yaml new file mode 100644 index 00000000..bd3b4eb3 --- /dev/null +++ b/tests/scenarios/shell/comments/hash_in_word.yaml @@ -0,0 +1,10 @@ +description: A hash sign in the middle of a word is literal, not a comment. +input: + script: |+ + V=abc#def + echo 123#456 $V +expect: + stdout: |+ + 123#456 abc#def + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/in_and_or.yaml b/tests/scenarios/shell/comments/in_and_or.yaml new file mode 100644 index 00000000..2e372075 --- /dev/null +++ b/tests/scenarios/shell/comments/in_and_or.yaml @@ -0,0 +1,12 @@ +description: A comment after && or || is ignored and the list continues. +input: + script: |+ + echo foo &&# comment after && + echo bar ||# comment after || + echo baz +expect: + stdout: |+ + foo + bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/in_brace_group.yaml b/tests/scenarios/shell/comments/in_brace_group.yaml new file mode 100644 index 00000000..ee2ac68f --- /dev/null +++ b/tests/scenarios/shell/comments/in_brace_group.yaml @@ -0,0 +1,11 @@ +description: Comments can appear inside brace groups. +input: + script: |+ + { # comment after open brace + echo foo # comment after command + } # comment after close brace +expect: + stdout: |+ + foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/in_for_loop.yaml b/tests/scenarios/shell/comments/in_for_loop.yaml new file mode 100644 index 00000000..326c8039 --- /dev/null +++ b/tests/scenarios/shell/comments/in_for_loop.yaml @@ -0,0 +1,15 @@ +description: Comments can appear between for loop tokens. +input: + script: |+ + for v # comment after variable + in 1 2 3 # comment after items + do # comment after do + echo $v # comment after body + done +expect: + stdout: |+ + 1 + 2 + 3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/in_for_loop_all_positions.yaml b/tests/scenarios/shell/comments/in_for_loop_all_positions.yaml new file mode 100644 index 00000000..150e1dae --- /dev/null +++ b/tests/scenarios/shell/comments/in_for_loop_all_positions.yaml @@ -0,0 +1,15 @@ +description: Comment inside for loop between keywords is ignored. +input: + script: |+ + for v # comment + in 1 2 3 # comment + do # comment + echo $v # comment + done # comment +expect: + stdout: |+ + 1 + 2 + 3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/in_pipeline.yaml b/tests/scenarios/shell/comments/in_pipeline.yaml new file mode 100644 index 00000000..18d4ec87 --- /dev/null +++ b/tests/scenarios/shell/comments/in_pipeline.yaml @@ -0,0 +1,10 @@ +description: A comment after a pipe operator is ignored and the pipeline continues. +input: + script: |+ + echo foo |# this is a comment + cat +expect: + stdout: |+ + foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/indented_comments.yaml b/tests/scenarios/shell/comments/indented_comments.yaml new file mode 100644 index 00000000..7d34d329 --- /dev/null +++ b/tests/scenarios/shell/comments/indented_comments.yaml @@ -0,0 +1,13 @@ +description: Leading spaces and tabs before hash still make it a comment. +input: + script: |+ + echo first + # indented with spaces + # indented with tab + echo second +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/inline.yaml b/tests/scenarios/shell/comments/inline.yaml new file mode 100644 index 00000000..381e9e90 --- /dev/null +++ b/tests/scenarios/shell/comments/inline.yaml @@ -0,0 +1,9 @@ +description: Inline comments after a command are ignored. +input: + script: |+ + echo hello # this is a comment +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/multiple_consecutive.yaml b/tests/scenarios/shell/comments/multiple_consecutive.yaml new file mode 100644 index 00000000..734f9ba3 --- /dev/null +++ b/tests/scenarios/shell/comments/multiple_consecutive.yaml @@ -0,0 +1,14 @@ +description: Multiple consecutive comment lines are all ignored. +input: + script: |+ + # comment 1 + # comment 2 + # comment 3 + echo ok + # comment 4 + # comment 5 +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/multiple_hashes.yaml b/tests/scenarios/shell/comments/multiple_hashes.yaml new file mode 100644 index 00000000..3051242b --- /dev/null +++ b/tests/scenarios/shell/comments/multiple_hashes.yaml @@ -0,0 +1,12 @@ +description: Multiple hash characters in sequence are still a comment. +input: + script: |+ + echo before + ### this is a comment with multiple hashes + echo after +expect: + stdout: |+ + before + after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/only_comments.yaml b/tests/scenarios/shell/comments/only_comments.yaml new file mode 100644 index 00000000..93ff5d9f --- /dev/null +++ b/tests/scenarios/shell/comments/only_comments.yaml @@ -0,0 +1,17 @@ +description: Script with only comments produces no output. +input: + script: |+ + # + + # foo + + # bar + # + # + + ##foo + ### +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/comments/standalone_between_commands.yaml b/tests/scenarios/shell/comments/standalone_between_commands.yaml new file mode 100644 index 00000000..6676d6b6 --- /dev/null +++ b/tests/scenarios/shell/comments/standalone_between_commands.yaml @@ -0,0 +1,13 @@ +description: Standalone comment lines between commands are ignored. +input: + script: |+ + echo first + # this is a standalone comment + # another standalone comment + echo second +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/empty_script/empty.yaml b/tests/scenarios/shell/empty_script/empty.yaml new file mode 100644 index 00000000..ed7fb6b1 --- /dev/null +++ b/tests/scenarios/shell/empty_script/empty.yaml @@ -0,0 +1,7 @@ +description: An empty script produces no output and exits successfully. +input: + script: "" +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/builtin_ifs_set.yaml b/tests/scenarios/shell/environment/builtin_ifs_set.yaml new file mode 100644 index 00000000..b199c2e0 --- /dev/null +++ b/tests/scenarios/shell/environment/builtin_ifs_set.yaml @@ -0,0 +1,11 @@ +description: The IFS builtin variable is set by the interpreter and controls word splitting. +input: + script: |+ + IFS=, + A="a,b,c" + echo $A +expect: + stdout: |+ + a b c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/empty_by_default.yaml b/tests/scenarios/shell/environment/empty_by_default.yaml new file mode 100644 index 00000000..71f6fe64 --- /dev/null +++ b/tests/scenarios/shell/environment/empty_by_default.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Without input.envs, no environment variables are available except builtins. +input: + script: |+ + echo "val=$SOME_RANDOM_VAR" +expect: + stdout: |+ + val= + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/empty_var_vs_unset.yaml b/tests/scenarios/shell/environment/empty_var_vs_unset.yaml new file mode 100644 index 00000000..1077d008 --- /dev/null +++ b/tests/scenarios/shell/environment/empty_var_vs_unset.yaml @@ -0,0 +1,10 @@ +description: "Explicitly empty variable (VAR=) differs from unset variable" +input: + script: | + EMPTY= + echo "empty=[$EMPTY]" + echo "unset=[$NEVER_SET]" + echo "both_blank" +expect: + stdout: "empty=[]\nunset=[]\nboth_blank\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/env_option_empty_value.yaml b/tests/scenarios/shell/environment/env_option_empty_value.yaml new file mode 100644 index 00000000..f24bfd1f --- /dev/null +++ b/tests/scenarios/shell/environment/env_option_empty_value.yaml @@ -0,0 +1,10 @@ +description: "Env option can provide empty-string values" +test_against_local_shell: false +input: + interpreter_env: + EMPTY_VAR: "" + script: | + echo "val=[$EMPTY_VAR]" +expect: + stdout: "val=[]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/env_option_field_splitting.yaml b/tests/scenarios/shell/environment/env_option_field_splitting.yaml new file mode 100644 index 00000000..876b8248 --- /dev/null +++ b/tests/scenarios/shell/environment/env_option_field_splitting.yaml @@ -0,0 +1,10 @@ +description: "Env option provides variables used in field splitting" +test_against_local_shell: false +input: + interpreter_env: + ITEMS: "a b c" + script: | + for w in $ITEMS; do echo "$w"; done +expect: + stdout: "a\nb\nc\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/env_option_no_extra_vars.yaml b/tests/scenarios/shell/environment/env_option_no_extra_vars.yaml new file mode 100644 index 00000000..a70eb362 --- /dev/null +++ b/tests/scenarios/shell/environment/env_option_no_extra_vars.yaml @@ -0,0 +1,11 @@ +description: "Variables from Env option do not pollute unrelated variable names" +test_against_local_shell: false +input: + interpreter_env: + DEFINED: "yes" + script: | + echo "defined=$DEFINED" + echo "other=$UNDEFINED_VAR" +expect: + stdout: "defined=yes\nother=\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/env_option_override.yaml b/tests/scenarios/shell/environment/env_option_override.yaml new file mode 100644 index 00000000..6b700e71 --- /dev/null +++ b/tests/scenarios/shell/environment/env_option_override.yaml @@ -0,0 +1,12 @@ +description: "Variables from Env option can be overridden by script assignment" +test_against_local_shell: false +input: + interpreter_env: + MY_VAR: "initial" + script: | + echo "$MY_VAR" + MY_VAR=overridden + echo "$MY_VAR" +expect: + stdout: "initial\noverridden\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/env_option_path_like_value.yaml b/tests/scenarios/shell/environment/env_option_path_like_value.yaml new file mode 100644 index 00000000..ed0ee867 --- /dev/null +++ b/tests/scenarios/shell/environment/env_option_path_like_value.yaml @@ -0,0 +1,10 @@ +description: "Env option with PATH-like value containing colons" +test_against_local_shell: false +input: + interpreter_env: + MY_PATH: "/usr/bin:/usr/local/bin:/bin" + script: | + echo "$MY_PATH" +expect: + stdout: "/usr/bin:/usr/local/bin:/bin\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/env_option_special_chars.yaml b/tests/scenarios/shell/environment/env_option_special_chars.yaml new file mode 100644 index 00000000..bcf369aa --- /dev/null +++ b/tests/scenarios/shell/environment/env_option_special_chars.yaml @@ -0,0 +1,10 @@ +description: "Env option variable with special characters in value" +test_against_local_shell: false +input: + interpreter_env: + SPECIAL: "hello world!@#" + script: | + echo "$SPECIAL" +expect: + stdout: "hello world!@#\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/env_option_vars_accessible.yaml b/tests/scenarios/shell/environment/env_option_vars_accessible.yaml new file mode 100644 index 00000000..7b50eee1 --- /dev/null +++ b/tests/scenarios/shell/environment/env_option_vars_accessible.yaml @@ -0,0 +1,11 @@ +description: "Variables provided via interpreter_env (Env option) are accessible" +test_against_local_shell: false +input: + interpreter_env: + MY_VAR: "hello" + OTHER_VAR: "world" + script: | + echo "$MY_VAR $OTHER_VAR" +expect: + stdout: "hello world\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/home_not_set.yaml b/tests/scenarios/shell/environment/home_not_set.yaml new file mode 100644 index 00000000..32f23b38 --- /dev/null +++ b/tests/scenarios/shell/environment/home_not_set.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: $HOME is not automatically set by the interpreter. +input: + script: |+ + echo "home=$HOME" +expect: + stdout: |+ + home= + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/ifs_default_tab.yaml b/tests/scenarios/shell/environment/ifs_default_tab.yaml new file mode 100644 index 00000000..9bca8414 --- /dev/null +++ b/tests/scenarios/shell/environment/ifs_default_tab.yaml @@ -0,0 +1,13 @@ +description: Default IFS splits on spaces, tabs, and newlines. +input: + script: |+ + A="hello world" + for w in $A; do + echo "$w" + done +expect: + stdout: |+ + hello + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/ifs_empty_no_split.yaml b/tests/scenarios/shell/environment/ifs_empty_no_split.yaml new file mode 100644 index 00000000..2a09c065 --- /dev/null +++ b/tests/scenarios/shell/environment/ifs_empty_no_split.yaml @@ -0,0 +1,13 @@ +description: Empty IFS disables word splitting. +input: + script: |+ + IFS= + A="hello world" + for w in $A; do + echo "$w" + done +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/ifs_multiple_custom_chars.yaml b/tests/scenarios/shell/environment/ifs_multiple_custom_chars.yaml new file mode 100644 index 00000000..a5d78d2a --- /dev/null +++ b/tests/scenarios/shell/environment/ifs_multiple_custom_chars.yaml @@ -0,0 +1,9 @@ +description: "IFS set to multiple non-whitespace characters splits on each" +input: + script: | + IFS=:, + A="one:two,three" + for w in $A; do echo "$w"; done +expect: + stdout: "one\ntwo\nthree\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/ifs_tab_only.yaml b/tests/scenarios/shell/environment/ifs_tab_only.yaml new file mode 100644 index 00000000..1fe9db34 --- /dev/null +++ b/tests/scenarios/shell/environment/ifs_tab_only.yaml @@ -0,0 +1,6 @@ +description: "IFS set to tab only splits on tabs, not spaces" +input: + script: "IFS=\"\t\"\nA=\"hello world\tfoo\"\nfor w in $A; do echo \"[$w]\"; done\n" +expect: + stdout: "[hello world]\n[foo]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/inline_assignment.yaml b/tests/scenarios/shell/environment/inline_assignment.yaml new file mode 100644 index 00000000..0b47b80a --- /dev/null +++ b/tests/scenarios/shell/environment/inline_assignment.yaml @@ -0,0 +1,10 @@ +description: Variables assigned in the script are accessible, even without input.envs. +input: + script: |+ + MY_VAR=hello + echo "$MY_VAR" +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/inline_assignment_temporary.yaml b/tests/scenarios/shell/environment/inline_assignment_temporary.yaml new file mode 100644 index 00000000..d1b8db09 --- /dev/null +++ b/tests/scenarios/shell/environment/inline_assignment_temporary.yaml @@ -0,0 +1,9 @@ +description: "Inline var assignment is temporary and does not persist past the command" +input: + script: | + X=before + X=temp echo "during" + echo "after=$X" +expect: + stdout: "during\nafter=before\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/lang_not_set.yaml b/tests/scenarios/shell/environment/lang_not_set.yaml new file mode 100644 index 00000000..fffd4830 --- /dev/null +++ b/tests/scenarios/shell/environment/lang_not_set.yaml @@ -0,0 +1,8 @@ +description: "LANG variable is not set in the default environment" +test_against_local_shell: false +input: + script: | + echo "lang=$LANG" +expect: + stdout: "lang=\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/multiple_assignments.yaml b/tests/scenarios/shell/environment/multiple_assignments.yaml new file mode 100644 index 00000000..64274486 --- /dev/null +++ b/tests/scenarios/shell/environment/multiple_assignments.yaml @@ -0,0 +1,10 @@ +description: "Multiple variables assigned in sequence are all accessible" +input: + script: | + A=one + B=two + C=three + echo "$A $B $C" +expect: + stdout: "one two three\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/no_parent_propagation.yaml b/tests/scenarios/shell/environment/no_parent_propagation.yaml new file mode 100644 index 00000000..923a6c30 --- /dev/null +++ b/tests/scenarios/shell/environment/no_parent_propagation.yaml @@ -0,0 +1,15 @@ +test_against_local_shell: false +description: Parent environment variables set via input.envs are not visible in the interpreter. +input: + envs: + MY_SECRET_KEY: supersecret + MY_API_TOKEN: abc123 + script: |+ + echo "key=$MY_SECRET_KEY" + echo "token=$MY_API_TOKEN" +expect: + stdout: |+ + key= + token= + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/optind_set_to_one.yaml b/tests/scenarios/shell/environment/optind_set_to_one.yaml new file mode 100644 index 00000000..7e18d42d --- /dev/null +++ b/tests/scenarios/shell/environment/optind_set_to_one.yaml @@ -0,0 +1,7 @@ +description: "OPTIND is set to 1 by default" +input: + script: | + echo "$OPTIND" +expect: + stdout: "1\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/override_provided.yaml b/tests/scenarios/shell/environment/override_provided.yaml new file mode 100644 index 00000000..4bc55fc6 --- /dev/null +++ b/tests/scenarios/shell/environment/override_provided.yaml @@ -0,0 +1,15 @@ +test_against_local_shell: false +description: Script can assign variables that exist as parent env vars without inheriting them. +input: + envs: + MY_VAR: parent_value + script: |+ + echo "$MY_VAR" + MY_VAR=script_value + echo "$MY_VAR" +expect: + stdout: |+ + + script_value + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/path_not_set.yaml b/tests/scenarios/shell/environment/path_not_set.yaml new file mode 100644 index 00000000..8303feca --- /dev/null +++ b/tests/scenarios/shell/environment/path_not_set.yaml @@ -0,0 +1,8 @@ +description: "PATH variable is not set in the default environment" +test_against_local_shell: false +input: + script: | + echo "path=$PATH" +expect: + stdout: "path=\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/provided_vars_accessible.yaml b/tests/scenarios/shell/environment/provided_vars_accessible.yaml new file mode 100644 index 00000000..7ed489e7 --- /dev/null +++ b/tests/scenarios/shell/environment/provided_vars_accessible.yaml @@ -0,0 +1,15 @@ +test_against_local_shell: false +description: Even common parent env vars like PATH are not propagated to the interpreter. +input: + envs: + PATH: /usr/bin:/bin + USER: testuser + script: |+ + echo "path=$PATH" + echo "user=$USER" +expect: + stdout: |+ + path= + user= + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/pwd_is_set.yaml b/tests/scenarios/shell/environment/pwd_is_set.yaml new file mode 100644 index 00000000..5a046d2e --- /dev/null +++ b/tests/scenarios/shell/environment/pwd_is_set.yaml @@ -0,0 +1,9 @@ +description: "PWD is set to the working directory" +target_os: [linux, darwin] +input: + script: | + echo "$PWD" +expect: + stdout_contains: + - "/" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/pwd_is_set_windows.yaml b/tests/scenarios/shell/environment/pwd_is_set_windows.yaml new file mode 100644 index 00000000..79e1d284 --- /dev/null +++ b/tests/scenarios/shell/environment/pwd_is_set_windows.yaml @@ -0,0 +1,9 @@ +description: "PWD is set to the working directory (Windows)" +target_os: [windows] +input: + script: | + echo "$PWD" +expect: + stdout_contains: + - "\\" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/shell_not_set.yaml b/tests/scenarios/shell/environment/shell_not_set.yaml new file mode 100644 index 00000000..ddb3c742 --- /dev/null +++ b/tests/scenarios/shell/environment/shell_not_set.yaml @@ -0,0 +1,8 @@ +description: "SHELL variable is not set in the default environment" +test_against_local_shell: false +input: + script: | + echo "shell=$SHELL" +expect: + stdout: "shell=\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/term_not_set.yaml b/tests/scenarios/shell/environment/term_not_set.yaml new file mode 100644 index 00000000..6edaff33 --- /dev/null +++ b/tests/scenarios/shell/environment/term_not_set.yaml @@ -0,0 +1,8 @@ +description: "TERM variable is not set in the default environment" +test_against_local_shell: false +input: + script: | + echo "term=$TERM" +expect: + stdout: "term=\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/tilde_not_expanded.yaml b/tests/scenarios/shell/environment/tilde_not_expanded.yaml new file mode 100644 index 00000000..c7b72a09 --- /dev/null +++ b/tests/scenarios/shell/environment/tilde_not_expanded.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Tilde expansion is disabled; ~ is passed through as a literal character. +input: + script: |+ + echo ~ +expect: + stdout: |+ + ~ + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/tilde_path_not_expanded.yaml b/tests/scenarios/shell/environment/tilde_path_not_expanded.yaml new file mode 100644 index 00000000..1c06e278 --- /dev/null +++ b/tests/scenarios/shell/environment/tilde_path_not_expanded.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Tilde with path ~/dir is not expanded. +input: + script: |+ + echo ~/mydir +expect: + stdout: |+ + ~/mydir + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/user_not_set.yaml b/tests/scenarios/shell/environment/user_not_set.yaml new file mode 100644 index 00000000..87b7a455 --- /dev/null +++ b/tests/scenarios/shell/environment/user_not_set.yaml @@ -0,0 +1,8 @@ +description: "USER variable is not set in the default environment" +test_against_local_shell: false +input: + script: | + echo "user=$USER" +expect: + stdout: "user=\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/variable_overwrite.yaml b/tests/scenarios/shell/environment/variable_overwrite.yaml new file mode 100644 index 00000000..e3bf0990 --- /dev/null +++ b/tests/scenarios/shell/environment/variable_overwrite.yaml @@ -0,0 +1,10 @@ +description: "Assigning the same variable twice uses the last value" +input: + script: | + X=first + echo "$X" + X=second + echo "$X" +expect: + stdout: "first\nsecond\n" + exit_code: 0 diff --git a/tests/scenarios/shell/environment/variable_with_spaces.yaml b/tests/scenarios/shell/environment/variable_with_spaces.yaml new file mode 100644 index 00000000..3a6c4f26 --- /dev/null +++ b/tests/scenarios/shell/environment/variable_with_spaces.yaml @@ -0,0 +1,8 @@ +description: "Variable values can contain spaces" +input: + script: | + MSG="hello world foo" + echo "$MSG" +expect: + stdout: "hello world foo\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/double_quotes_prevent_globbing.yaml b/tests/scenarios/shell/field_splitting/double_quotes_prevent_globbing.yaml new file mode 100644 index 00000000..d1703abc --- /dev/null +++ b/tests/scenarios/shell/field_splitting/double_quotes_prevent_globbing.yaml @@ -0,0 +1,14 @@ +description: Double-quoted variable expansion prevents pathname (glob) expansion. +setup: + files: + - path: "abc.txt" + content: "test" +input: + script: |+ + A="*.txt" + echo "$A" +expect: + stdout: |+ + *.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/double_quotes_prevent_splitting.yaml b/tests/scenarios/shell/field_splitting/double_quotes_prevent_splitting.yaml new file mode 100644 index 00000000..f73d9ea3 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/double_quotes_prevent_splitting.yaml @@ -0,0 +1,10 @@ +description: Double-quoted variable expansion prevents field splitting. +input: + script: |+ + A="hello world foo" + for w in "$A"; do echo "[$w]"; done +expect: + stdout: |+ + [hello world foo] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/echo_args_custom_ifs.yaml b/tests/scenarios/shell/field_splitting/echo_args_custom_ifs.yaml new file mode 100644 index 00000000..d69fb6a9 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/echo_args_custom_ifs.yaml @@ -0,0 +1,9 @@ +description: "Field splitting in echo args with custom IFS" +input: + script: | + IFS=: + A="one:two:three" + echo $A +expect: + stdout: "one two three\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/empty_ifs_no_split_any.yaml b/tests/scenarios/shell/field_splitting/empty_ifs_no_split_any.yaml new file mode 100644 index 00000000..9bab6201 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/empty_ifs_no_split_any.yaml @@ -0,0 +1,9 @@ +description: "Empty IFS means no splitting happens on any character" +input: + script: | + IFS= + A="hello world:foo" + for w in $A; do echo "[$w]"; done +expect: + stdout: "[hello world:foo]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/empty_quoted_preserved.yaml b/tests/scenarios/shell/field_splitting/empty_quoted_preserved.yaml new file mode 100644 index 00000000..38e8351c --- /dev/null +++ b/tests/scenarios/shell/field_splitting/empty_quoted_preserved.yaml @@ -0,0 +1,10 @@ +description: Empty quoted variable expansion is preserved as an empty field. +input: + script: |+ + A="" + for w in "$A"; do echo "[$w]"; done +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/empty_unquoted_removed.yaml b/tests/scenarios/shell/field_splitting/empty_unquoted_removed.yaml new file mode 100644 index 00000000..a87c7fb9 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/empty_unquoted_removed.yaml @@ -0,0 +1,11 @@ +description: Empty unquoted variable expansion produces no fields and is removed. +input: + script: |+ + A="" + for w in $A; do echo "word: $w"; done + echo "done" +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/ifs_change_affects_later.yaml b/tests/scenarios/shell/field_splitting/ifs_change_affects_later.yaml new file mode 100644 index 00000000..3025c866 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/ifs_change_affects_later.yaml @@ -0,0 +1,10 @@ +description: "IFS change affects splitting of subsequent expansions in the same script" +input: + script: | + A="a:b:c" + for w in $A; do echo "[$w]"; done + IFS=: + for w in $A; do echo "[$w]"; done +expect: + stdout: "[a:b:c]\n[a]\n[b]\n[c]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/ifs_colon_separator.yaml b/tests/scenarios/shell/field_splitting/ifs_colon_separator.yaml new file mode 100644 index 00000000..588e5ec4 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/ifs_colon_separator.yaml @@ -0,0 +1,13 @@ +description: Custom IFS splits variable expansion on the specified delimiter. +input: + script: |+ + IFS=: + A="one:two:three" + for w in $A; do echo "$w"; done +expect: + stdout: |+ + one + two + three + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/ifs_equals_sign.yaml b/tests/scenarios/shell/field_splitting/ifs_equals_sign.yaml new file mode 100644 index 00000000..2383943b --- /dev/null +++ b/tests/scenarios/shell/field_splitting/ifs_equals_sign.yaml @@ -0,0 +1,9 @@ +description: "IFS with equals sign splits correctly" +input: + script: | + IFS== + A="key=value=extra" + for w in $A; do echo "$w"; done +expect: + stdout: "key\nvalue\nextra\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/ifs_pipe_char.yaml b/tests/scenarios/shell/field_splitting/ifs_pipe_char.yaml new file mode 100644 index 00000000..5c7801ef --- /dev/null +++ b/tests/scenarios/shell/field_splitting/ifs_pipe_char.yaml @@ -0,0 +1,9 @@ +description: "IFS with pipe character splits correctly" +input: + script: | + IFS="|" + A="one|two|three" + for w in $A; do echo "$w"; done +expect: + stdout: "one\ntwo\nthree\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/ifs_whitespace_coalescing.yaml b/tests/scenarios/shell/field_splitting/ifs_whitespace_coalescing.yaml new file mode 100644 index 00000000..5fc119df --- /dev/null +++ b/tests/scenarios/shell/field_splitting/ifs_whitespace_coalescing.yaml @@ -0,0 +1,11 @@ +description: Consecutive whitespace characters in IFS are coalesced into a single delimiter. +input: + script: |+ + A="hello world" + for w in $A; do echo "[$w]"; done +expect: + stdout: |+ + [hello] + [world] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/leading_nonws_ifs_empty_field.yaml b/tests/scenarios/shell/field_splitting/leading_nonws_ifs_empty_field.yaml new file mode 100644 index 00000000..8adafb8d --- /dev/null +++ b/tests/scenarios/shell/field_splitting/leading_nonws_ifs_empty_field.yaml @@ -0,0 +1,9 @@ +description: "Non-whitespace IFS splits correctly on values starting with data" +input: + script: | + IFS=: + A="first:second:third" + for w in $A; do echo "[$w]"; done +expect: + stdout: "[first]\n[second]\n[third]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/leading_trailing_ws_trimmed.yaml b/tests/scenarios/shell/field_splitting/leading_trailing_ws_trimmed.yaml new file mode 100644 index 00000000..beac3742 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/leading_trailing_ws_trimmed.yaml @@ -0,0 +1,8 @@ +description: "Leading whitespace in IFS is trimmed during splitting" +input: + script: | + A=" hello world " + for w in $A; do echo "[$w]"; done +expect: + stdout: "[hello]\n[world]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/literal_not_split.yaml b/tests/scenarios/shell/field_splitting/literal_not_split.yaml new file mode 100644 index 00000000..057f2fb9 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/literal_not_split.yaml @@ -0,0 +1,8 @@ +description: "Literal text is not subject to field splitting regardless of IFS" +input: + script: | + IFS=" 0" + echo -102- "-304 5-" +expect: + stdout: "-102- -304 5-\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/mixed_tabs_spaces_coalesce.yaml b/tests/scenarios/shell/field_splitting/mixed_tabs_spaces_coalesce.yaml new file mode 100644 index 00000000..f6d071ff --- /dev/null +++ b/tests/scenarios/shell/field_splitting/mixed_tabs_spaces_coalesce.yaml @@ -0,0 +1,6 @@ +description: "Tabs and spaces mixed in value are coalesced by default IFS" +input: + script: "A=\"one \t two \t three\"\nfor w in $A; do echo \"[$w]\"; done\n" +expect: + stdout: "[one]\n[two]\n[three]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/mixed_ws_nonws_ifs.yaml b/tests/scenarios/shell/field_splitting/mixed_ws_nonws_ifs.yaml new file mode 100644 index 00000000..15f9a7fc --- /dev/null +++ b/tests/scenarios/shell/field_splitting/mixed_ws_nonws_ifs.yaml @@ -0,0 +1,9 @@ +description: "Whitespace IFS characters coalesce, non-whitespace splits" +input: + script: | + IFS=" -" + A="a b-c" + for w in $A; do echo "[$w]"; done +expect: + stdout: "[a]\n[b]\n[c]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/multiple_consecutive_nonws_ifs.yaml b/tests/scenarios/shell/field_splitting/multiple_consecutive_nonws_ifs.yaml new file mode 100644 index 00000000..07c06bef --- /dev/null +++ b/tests/scenarios/shell/field_splitting/multiple_consecutive_nonws_ifs.yaml @@ -0,0 +1,9 @@ +description: "IFS with colon splits on each colon delimiter" +input: + script: | + IFS=: + A="a:b:c:d" + for w in $A; do echo "[$w]"; done +expect: + stdout: "[a]\n[b]\n[c]\n[d]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/newline_splitting_default_ifs.yaml b/tests/scenarios/shell/field_splitting/newline_splitting_default_ifs.yaml new file mode 100644 index 00000000..d050b321 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/newline_splitting_default_ifs.yaml @@ -0,0 +1,6 @@ +description: "Newlines in variable value are split by default IFS" +input: + script: "A=\"line1\nline2\nline3\"\nfor w in $A; do echo \"[$w]\"; done\n" +expect: + stdout: "[line1]\n[line2]\n[line3]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/nonws_ifs_empty_fields.yaml b/tests/scenarios/shell/field_splitting/nonws_ifs_empty_fields.yaml new file mode 100644 index 00000000..e4e0aa6d --- /dev/null +++ b/tests/scenarios/shell/field_splitting/nonws_ifs_empty_fields.yaml @@ -0,0 +1,9 @@ +description: "Non-whitespace IFS characters split on each delimiter" +input: + script: | + IFS=- + A="a-b-c" + for w in $A; do echo "[$w]"; done +expect: + stdout: "[a]\n[b]\n[c]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/quoted_empty_preserved_multiple.yaml b/tests/scenarios/shell/field_splitting/quoted_empty_preserved_multiple.yaml new file mode 100644 index 00000000..78df0671 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/quoted_empty_preserved_multiple.yaml @@ -0,0 +1,9 @@ +description: "Quoted double expansion preserves empty fields in for loop" +input: + script: | + A="" + B="" + for w in "$A" "$B"; do echo "[$w]"; done +expect: + stdout: "[]\n[]\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/single_quoted_no_expand.yaml b/tests/scenarios/shell/field_splitting/single_quoted_no_expand.yaml new file mode 100644 index 00000000..4cc028a3 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/single_quoted_no_expand.yaml @@ -0,0 +1,8 @@ +description: "Single-quoted strings prevent variable expansion, keeping literal dollar sign" +input: + script: | + A="hello world" + echo '$A' +expect: + stdout: "$A\n" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/unquoted_var_splits.yaml b/tests/scenarios/shell/field_splitting/unquoted_var_splits.yaml new file mode 100644 index 00000000..be09aede --- /dev/null +++ b/tests/scenarios/shell/field_splitting/unquoted_var_splits.yaml @@ -0,0 +1,12 @@ +description: Unquoted variable expansion is split on default IFS whitespace. +input: + script: |+ + A="hello world foo" + for w in $A; do echo "[$w]"; done +expect: + stdout: |+ + [hello] + [world] + [foo] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/field_splitting/var_in_for_items.yaml b/tests/scenarios/shell/field_splitting/var_in_for_items.yaml new file mode 100644 index 00000000..18eac1c2 --- /dev/null +++ b/tests/scenarios/shell/field_splitting/var_in_for_items.yaml @@ -0,0 +1,12 @@ +description: Variable expansion in for loop word list respects field splitting. +input: + script: |+ + ITEMS="a b c" + for i in $ITEMS; do echo "$i"; done +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/and_or_in_body.yaml b/tests/scenarios/shell/for_clause/basic/and_or_in_body.yaml new file mode 100644 index 00000000..5aacf5a4 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/and_or_in_body.yaml @@ -0,0 +1,17 @@ +description: And-or lists work correctly inside for loop body. +input: + script: |+ + for i in a b c; do + true && echo "ok:$i" + done + for i in x; do + false && echo "skip" || echo "fallback:$i" + done +expect: + stdout: |+ + ok:a + ok:b + ok:c + fallback:x + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/brace_in_body.yaml b/tests/scenarios/shell/for_clause/basic/brace_in_body.yaml new file mode 100644 index 00000000..cfdfd826 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/brace_in_body.yaml @@ -0,0 +1,14 @@ +description: Brace group can be used inside for loop body. +input: + script: |+ + for i in 1 2; do + { echo "a:$i"; echo "b:$i"; } + done +expect: + stdout: |+ + a:1 + b:1 + a:2 + b:2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/commands_after.yaml b/tests/scenarios/shell/for_clause/basic/commands_after.yaml new file mode 100644 index 00000000..d3d735fe --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/commands_after.yaml @@ -0,0 +1,12 @@ +description: Commands after a for loop execute normally. +input: + script: |+ + for i in a b; do echo $i; done + echo after +expect: + stdout: |+ + a + b + after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/commands_before.yaml b/tests/scenarios/shell/for_clause/basic/commands_before.yaml new file mode 100644 index 00000000..308dce42 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/commands_before.yaml @@ -0,0 +1,12 @@ +description: Commands before a for loop execute normally. +input: + script: |+ + echo before + for i in a b; do echo $i; done +expect: + stdout: |+ + before + a + b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/do_as_word.yaml b/tests/scenarios/shell/for_clause/basic/do_as_word.yaml new file mode 100644 index 00000000..ef2382e2 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/do_as_word.yaml @@ -0,0 +1,9 @@ +description: The word "do" can be used as a for loop item. +input: + script: |+ + for i in do; do echo $i; done +expect: + stdout: |+ + do + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/do_done_as_words.yaml b/tests/scenarios/shell/for_clause/basic/do_done_as_words.yaml new file mode 100644 index 00000000..efaba5ef --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/do_done_as_words.yaml @@ -0,0 +1,10 @@ +description: The words "do" and "done" can be used as for loop items. +input: + script: |+ + for word in do done; do echo $word; done +expect: + stdout: |+ + do + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/empty_body.yaml b/tests/scenarios/shell/for_clause/basic/empty_body.yaml new file mode 100644 index 00000000..7fcd5261 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/empty_body.yaml @@ -0,0 +1,12 @@ +description: Empty for loop body executes successfully with no output. +input: + script: |+ + for i in a b c; do + true + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/empty_list.yaml b/tests/scenarios/shell/for_clause/basic/empty_list.yaml new file mode 100644 index 00000000..f7182421 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/empty_list.yaml @@ -0,0 +1,10 @@ +description: For loop with empty list executes zero iterations. +input: + script: |+ + for i in; do echo $i; done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/exit_in_body.yaml b/tests/scenarios/shell/for_clause/basic/exit_in_body.yaml new file mode 100644 index 00000000..04e7c875 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/exit_in_body.yaml @@ -0,0 +1,14 @@ +description: Exit inside for loop body terminates the entire script immediately. +input: + script: |+ + for i in a b c; do + echo $i + exit 0 + echo not reached + done + echo also not reached +expect: + stdout: |+ + a + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/exit_nonzero_in_body.yaml b/tests/scenarios/shell/for_clause/basic/exit_nonzero_in_body.yaml new file mode 100644 index 00000000..37a845a8 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/exit_nonzero_in_body.yaml @@ -0,0 +1,12 @@ +description: Exit with non-zero code inside for loop body terminates with that code. +input: + script: |+ + for i in a b c; do + echo $i + exit 5 + done +expect: + stdout: |+ + a + stderr: "" + exit_code: 5 diff --git a/tests/scenarios/shell/for_clause/basic/for_as_varname.yaml b/tests/scenarios/shell/for_clause/basic/for_as_varname.yaml new file mode 100644 index 00000000..acace5f4 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/for_as_varname.yaml @@ -0,0 +1,10 @@ +description: The word "for" can be used as the loop variable name. +input: + script: |+ + for for in x y; do echo $for; done +expect: + stdout: |+ + x + y + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/in_as_varname.yaml b/tests/scenarios/shell/for_clause/basic/in_as_varname.yaml new file mode 100644 index 00000000..ed395401 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/in_as_varname.yaml @@ -0,0 +1,9 @@ +description: The word "in" can be used as the loop variable name. +input: + script: |+ + for in in foo; do echo $in; done +expect: + stdout: |+ + foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/in_as_word.yaml b/tests/scenarios/shell/for_clause/basic/in_as_word.yaml new file mode 100644 index 00000000..ccbd68bd --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/in_as_word.yaml @@ -0,0 +1,9 @@ +description: The word "in" can be used as a for loop item. +input: + script: |+ + for i in in; do echo $i; done +expect: + stdout: |+ + in + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/iterate_words.yaml b/tests/scenarios/shell/for_clause/basic/iterate_words.yaml new file mode 100644 index 00000000..3c1e1475 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/iterate_words.yaml @@ -0,0 +1,11 @@ +description: For loop iterates over a list of words. +input: + script: |+ + for i in a b c; do echo $i; done +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/loop_var_overwrite.yaml b/tests/scenarios/shell/for_clause/basic/loop_var_overwrite.yaml new file mode 100644 index 00000000..c70a2d5f --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/loop_var_overwrite.yaml @@ -0,0 +1,18 @@ +description: Reassigning the loop variable inside the body does not affect iteration. +input: + script: |+ + for i in a b c; do + echo "before:$i" + i=overwritten + echo "after:$i" + done +expect: + stdout: |+ + before:a + after:overwritten + before:b + after:overwritten + before:c + after:overwritten + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/many_items.yaml b/tests/scenarios/shell/for_clause/basic/many_items.yaml new file mode 100644 index 00000000..0d6a05be --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/many_items.yaml @@ -0,0 +1,13 @@ +description: For loop iterates over many items in order. +input: + script: |+ + for n in 1 2 3 4 5; do echo $n; done +expect: + stdout: |+ + 1 + 2 + 3 + 4 + 5 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/multiline_body.yaml b/tests/scenarios/shell/for_clause/basic/multiline_body.yaml new file mode 100644 index 00000000..ad6230c0 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/multiline_body.yaml @@ -0,0 +1,15 @@ +description: For loop body can span multiple lines. +input: + script: |+ + for i in a b; do + echo start $i + echo end $i + done +expect: + stdout: |+ + start a + end a + start b + end b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/multiple_loops_sequence.yaml b/tests/scenarios/shell/for_clause/basic/multiple_loops_sequence.yaml new file mode 100644 index 00000000..a60f84f5 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/multiple_loops_sequence.yaml @@ -0,0 +1,15 @@ +description: Multiple for loops in sequence execute one after another. +input: + script: |+ + for i in a b; do echo "first:$i"; done + for j in 1 2; do echo "second:$j"; done + for k in x; do echo "third:$k"; done +expect: + stdout: |+ + first:a + first:b + second:1 + second:2 + third:x + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/negation_in_body.yaml b/tests/scenarios/shell/for_clause/basic/negation_in_body.yaml new file mode 100644 index 00000000..07640403 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/negation_in_body.yaml @@ -0,0 +1,13 @@ +description: Negation works inside for loop body. +input: + script: |+ + for i in a b; do + ! false + echo "$i:$?" + done +expect: + stdout: |+ + a:0 + b:0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/newline_separated_do.yaml b/tests/scenarios/shell/for_clause/basic/newline_separated_do.yaml new file mode 100644 index 00000000..25d887f0 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/newline_separated_do.yaml @@ -0,0 +1,13 @@ +description: Newline before "do" keyword in for loop is valid. +input: + script: |+ + for i in a b + do + echo $i + done +expect: + stdout: |+ + a + b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/pipe_in_body.yaml b/tests/scenarios/shell/for_clause/basic/pipe_in_body.yaml new file mode 100644 index 00000000..05010d50 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/pipe_in_body.yaml @@ -0,0 +1,12 @@ +description: Pipe command inside a for loop body pipes each iteration independently. +input: + script: |+ + for i in hello world; do + echo ">>$i" | cat + done +expect: + stdout: |+ + >>hello + >>world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/reserved_words_as_items.yaml b/tests/scenarios/shell/for_clause/basic/reserved_words_as_items.yaml new file mode 100644 index 00000000..3848f499 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/reserved_words_as_items.yaml @@ -0,0 +1,12 @@ +description: Shell reserved words used as for loop item values are treated as plain strings. +input: + script: |+ + for w in for do done in; do echo "$w"; done +expect: + stdout: |+ + for + do + done + in + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/semicolon_do.yaml b/tests/scenarios/shell/for_clause/basic/semicolon_do.yaml new file mode 100644 index 00000000..b0efcf59 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/semicolon_do.yaml @@ -0,0 +1,10 @@ +description: For loop with semicolons separating do and done clauses. +input: + script: |+ + for v in 1 2; do echo $v; done +expect: + stdout: |+ + 1 + 2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/single_item.yaml b/tests/scenarios/shell/for_clause/basic/single_item.yaml new file mode 100644 index 00000000..cad80776 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/single_item.yaml @@ -0,0 +1,9 @@ +description: For loop with a single item iterates once. +input: + script: |+ + for x in hello; do echo $x; done +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/single_line_compact.yaml b/tests/scenarios/shell/for_clause/basic/single_line_compact.yaml new file mode 100644 index 00000000..8d1e91e7 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/single_line_compact.yaml @@ -0,0 +1,14 @@ +description: For loop written entirely on one line with semicolons. +input: + script: |+ + for i in a b c; do echo $i; echo --; done +expect: + stdout: |+ + a + -- + b + -- + c + -- + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/special_characters_in_items.yaml b/tests/scenarios/shell/for_clause/basic/special_characters_in_items.yaml new file mode 100644 index 00000000..0a4e793c --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/special_characters_in_items.yaml @@ -0,0 +1,10 @@ +description: For loop items can contain special characters when quoted. +input: + script: |+ + for f in "hello world" "foo bar"; do echo "$f"; done +expect: + stdout: |+ + hello world + foo bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/tab_separated_tokens.yaml b/tests/scenarios/shell/for_clause/basic/tab_separated_tokens.yaml new file mode 100644 index 00000000..422bda31 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/tab_separated_tokens.yaml @@ -0,0 +1,9 @@ +description: For loop tokens can be separated by tabs instead of spaces. +input: + script: "for\ti\tin\ta\tb; do echo $i; done\n" +expect: + stdout: |+ + a + b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/basic/words_not_assignments.yaml b/tests/scenarios/shell/for_clause/basic/words_not_assignments.yaml new file mode 100644 index 00000000..baab84a3 --- /dev/null +++ b/tests/scenarios/shell/for_clause/basic/words_not_assignments.yaml @@ -0,0 +1,10 @@ +description: Words in for loop items are not treated as variable assignments. +input: + script: |+ + V=foo + for i in V=bar; do echo $i $V; done +expect: + stdout: |+ + V=bar foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_after_and.yaml b/tests/scenarios/shell/for_clause/break_cont/break_after_and.yaml new file mode 100644 index 00000000..cf8eef8d --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_after_and.yaml @@ -0,0 +1,13 @@ +description: Break after && exits the loop when the left side succeeds. +input: + script: |+ + for i in 1; do + true && break + echo not reached + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_after_or.yaml b/tests/scenarios/shell/for_clause/break_cont/break_after_or.yaml new file mode 100644 index 00000000..a2b65300 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_after_or.yaml @@ -0,0 +1,13 @@ +description: Break after || exits the loop when the left side fails. +input: + script: |+ + for i in 1; do + false || break + echo not reached + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_before_and.yaml b/tests/scenarios/shell/for_clause/break_cont/break_before_and.yaml new file mode 100644 index 00000000..587354fc --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_before_and.yaml @@ -0,0 +1,8 @@ +description: Break before && operator prevents subsequent commands. +input: + script: |+ + for i in 1; do break && echo not reached 1; echo not reached 2; done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_before_or.yaml b/tests/scenarios/shell/for_clause/break_cont/break_before_or.yaml new file mode 100644 index 00000000..7f3d0818 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_before_or.yaml @@ -0,0 +1,8 @@ +description: Break before || operator prevents subsequent commands. +input: + script: |+ + for i in 1; do break || echo not reached 1; echo not reached 2; done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_default_in_triple_nested.yaml b/tests/scenarios/shell/for_clause/break_cont/break_default_in_triple_nested.yaml new file mode 100644 index 00000000..9098926b --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_default_in_triple_nested.yaml @@ -0,0 +1,27 @@ +description: Break with default operand only breaks innermost loop in triple nesting. +input: + script: |+ + for i in 1; do + echo in $i + for j in a; do + echo in $i $j + for k in +; do + echo in $i $j $k + break + echo out $i $j $k + done + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 1 a + + out 1 a + out 1 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_exceeds_depth.yaml b/tests/scenarios/shell/for_clause/break_cont/break_exceeds_depth.yaml new file mode 100644 index 00000000..6cc5013f --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_exceeds_depth.yaml @@ -0,0 +1,14 @@ +description: Break with argument exceeding nesting depth breaks all loops. +input: + script: |+ + for i in a b c; do + echo $i + break 99 + done + echo done +expect: + stdout: |+ + a + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_from_brace.yaml b/tests/scenarios/shell/for_clause/break_cont/break_from_brace.yaml new file mode 100644 index 00000000..097833b5 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_from_brace.yaml @@ -0,0 +1,13 @@ +description: Break inside a brace group exits the enclosing for loop. +input: + script: |+ + for i in 1; do + { break; } + echo not reached + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_inner_continues_outer.yaml b/tests/scenarios/shell/for_clause/break_cont/break_inner_continues_outer.yaml new file mode 100644 index 00000000..1b5efcc7 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_inner_continues_outer.yaml @@ -0,0 +1,27 @@ +description: Break 1 in nested for loop only breaks the inner loop. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + break 1 + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + out 1 + in 2 + in 2 a + out 2 + in 3 + in 3 a + out 3 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_invalid_arg.yaml b/tests/scenarios/shell/for_clause/break_cont/break_invalid_arg.yaml new file mode 100644 index 00000000..1e34f9ec --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_invalid_arg.yaml @@ -0,0 +1,11 @@ +description: Break with non-numeric argument exits the shell and produces an error. +input: + script: |+ + for i in a b c; do + break abc + done +expect: + stdout: "" + stderr_contains: + - "break: abc: numeric argument required" + exit_code: 128 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_mid_body.yaml b/tests/scenarios/shell/for_clause/break_cont/break_mid_body.yaml new file mode 100644 index 00000000..22a07feb --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_mid_body.yaml @@ -0,0 +1,13 @@ +description: Break in the middle of the loop body stops execution. +input: + script: |+ + for i in a b c; do + echo "start $i" + break + echo "end $i" + done +expect: + stdout: |+ + start a + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_much_exceeds_depth.yaml b/tests/scenarios/shell/for_clause/break_cont/break_much_exceeds_depth.yaml new file mode 100644 index 00000000..edbd4108 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_much_exceeds_depth.yaml @@ -0,0 +1,11 @@ +description: Break with much larger than nesting level breaks all loops. +input: + script: |+ + for i in 1; do + break 100 + echo not reached + done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_multiple_args.yaml b/tests/scenarios/shell/for_clause/break_cont/break_multiple_args.yaml new file mode 100644 index 00000000..eb58d20f --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_multiple_args.yaml @@ -0,0 +1,12 @@ +description: Break with multiple arguments produces an error. +input: + script: |+ + for i in a b c; do + break 1 2 + done +expect: + stdout: "" + stderr_contains: + - "break" + - "too many arguments" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_outside_loop.yaml b/tests/scenarios/shell/for_clause/break_cont/break_outside_loop.yaml new file mode 100644 index 00000000..0f894d05 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_outside_loop.yaml @@ -0,0 +1,12 @@ +test_against_local_shell: false +description: Break outside a loop produces an error message. +input: + script: |+ + break + echo after +expect: + stdout: |+ + after + stderr: |+ + break is only useful in a loop + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_simple.yaml b/tests/scenarios/shell/for_clause/break_cont/break_simple.yaml new file mode 100644 index 00000000..64dae3b4 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_simple.yaml @@ -0,0 +1,14 @@ +description: Break exits the for loop early. +input: + script: |+ + for i in a b c d; do + echo $i + break + done + echo after +expect: + stdout: |+ + a + after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_three_nested_outermost.yaml b/tests/scenarios/shell/for_clause/break_cont/break_three_nested_outermost.yaml new file mode 100644 index 00000000..fc38a974 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_three_nested_outermost.yaml @@ -0,0 +1,25 @@ +description: Break 3 in triple nested for loops breaks all three. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + for k in + -; do + echo in $i $j $k + break 3 + echo out $i $j $k + done + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 1 a + + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_two_in_triple_nested.yaml b/tests/scenarios/shell/for_clause/break_cont/break_two_in_triple_nested.yaml new file mode 100644 index 00000000..1d1dc290 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_two_in_triple_nested.yaml @@ -0,0 +1,34 @@ +description: Break 2 in triple nested for loops breaks the inner two, outer continues. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + for k in + -; do + echo in $i $j $k + break 2 + echo out $i $j $k + done + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 1 a + + out 1 + in 2 + in 2 a + in 2 a + + out 2 + in 3 + in 3 a + in 3 a + + out 3 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_two_nested.yaml b/tests/scenarios/shell/for_clause/break_cont/break_two_nested.yaml new file mode 100644 index 00000000..a15553b6 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_two_nested.yaml @@ -0,0 +1,26 @@ +description: Break 2 in triple nested for loop breaks the inner two loops. +input: + script: |+ + for i in 1 2; do + echo in $i + for j in a b; do + for k in + -; do + echo in $i $j $k + break 2 + done + echo mid + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + + out 1 + in 2 + in 2 a + + out 2 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_two_outermost.yaml b/tests/scenarios/shell/for_clause/break_cont/break_two_outermost.yaml new file mode 100644 index 00000000..fe3840aa --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_two_outermost.yaml @@ -0,0 +1,20 @@ +description: Break 2 in double nested for loops breaks both. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + break 2 + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_with_arg.yaml b/tests/scenarios/shell/for_clause/break_cont/break_with_arg.yaml new file mode 100644 index 00000000..2e35982f --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_with_arg.yaml @@ -0,0 +1,14 @@ +description: Break with argument 1 breaks one level (same as simple break). +input: + script: |+ + for i in a b c; do + echo $i + break 1 + done + echo done +expect: + stdout: |+ + a + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_with_negation.yaml b/tests/scenarios/shell/for_clause/break_cont/break_with_negation.yaml new file mode 100644 index 00000000..9524ca19 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_with_negation.yaml @@ -0,0 +1,11 @@ +description: Break with negation prefix breaks the loop with inverted exit code. +input: + script: |+ + for i in 1; do + ! break + echo not reached + done +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_zero_arg.yaml b/tests/scenarios/shell/for_clause/break_cont/break_zero_arg.yaml new file mode 100644 index 00000000..d7998a54 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_zero_arg.yaml @@ -0,0 +1,13 @@ +description: Break 0 is an invalid operand and produces an error. +input: + script: |+ + for i in 1; do + break 0 + done +expect: + stdout: "" + stderr_contains: + - "break" + - "0" + - "loop count out of range" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_after_and.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_after_and.yaml new file mode 100644 index 00000000..b295e1fe --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_after_and.yaml @@ -0,0 +1,13 @@ +description: Continue after && skips to next iteration when left side succeeds. +input: + script: |+ + for i in 1 2; do + true && continue + echo not reached + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_after_or.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_after_or.yaml new file mode 100644 index 00000000..4066636d --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_after_or.yaml @@ -0,0 +1,13 @@ +description: Continue after || skips to next iteration when left side fails. +input: + script: |+ + for i in 1 2; do + false || continue + echo not reached + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_before_and.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_before_and.yaml new file mode 100644 index 00000000..bc8ad5af --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_before_and.yaml @@ -0,0 +1,8 @@ +description: Continue before && operator prevents subsequent commands in the iteration. +input: + script: |+ + for i in 1; do continue && echo not reached; echo not reached 2; done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_before_or.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_before_or.yaml new file mode 100644 index 00000000..d7d2a1b6 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_before_or.yaml @@ -0,0 +1,8 @@ +description: Continue before || operator prevents subsequent commands in the iteration. +input: + script: |+ + for i in 1; do continue || echo not reached; echo not reached 2; done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_default_in_triple_nested.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_default_in_triple_nested.yaml new file mode 100644 index 00000000..71573a49 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_default_in_triple_nested.yaml @@ -0,0 +1,28 @@ +description: Continue with default operand only continues innermost loop in triple nesting. +input: + script: |+ + for i in 1; do + echo in $i + for j in a; do + echo in $i $j + for k in + -; do + echo in $i $j $k + continue + echo out $i $j $k + done + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 1 a + + in 1 a - + out 1 a + out 1 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_default_operand.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_default_operand.yaml new file mode 100644 index 00000000..91574b07 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_default_operand.yaml @@ -0,0 +1,24 @@ +description: Continue without operand defaults to 1. +input: + script: |+ + for i in 1; do + for j in a; do + for k in + -; do + echo in $i $j $k + continue + echo out + done + echo out $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 a + + in 1 a - + out a + out 1 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_exceeds_depth.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_exceeds_depth.yaml new file mode 100644 index 00000000..a608742b --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_exceeds_depth.yaml @@ -0,0 +1,8 @@ +description: Continue with level exceeding nesting depth continues all loops. +input: + script: |+ + for i in 1; do continue 2; echo not reached; done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_from_brace.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_from_brace.yaml new file mode 100644 index 00000000..7142a3bf --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_from_brace.yaml @@ -0,0 +1,15 @@ +description: Continue inside a brace group skips to next iteration of the enclosing for loop. +input: + script: |+ + for i in 1 2; do + { echo $i; continue; } + echo not reached + done + echo done +expect: + stdout: |+ + 1 + 2 + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_inner_in_nested.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_inner_in_nested.yaml new file mode 100644 index 00000000..eb44ba42 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_inner_in_nested.yaml @@ -0,0 +1,33 @@ +description: Continue 1 in nested for loop only continues the inner loop. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + continue 1 + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 1 b + in 1 c + out 1 + in 2 + in 2 a + in 2 b + in 2 c + out 2 + in 3 + in 3 a + in 3 b + in 3 c + out 3 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_invalid_arg.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_invalid_arg.yaml new file mode 100644 index 00000000..b7f1989f --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_invalid_arg.yaml @@ -0,0 +1,11 @@ +description: Continue with non-numeric argument exits the shell and produces an error. +input: + script: |+ + for i in a b c; do + continue abc + done +expect: + stdout: "" + stderr_contains: + - "continue: abc: numeric argument required" + exit_code: 128 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_much_exceeds_depth.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_much_exceeds_depth.yaml new file mode 100644 index 00000000..9b92caf4 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_much_exceeds_depth.yaml @@ -0,0 +1,11 @@ +description: Continue with much larger than nesting level continues the outermost loop. +input: + script: |+ + for i in 1; do + continue 100 + echo not reached + done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_multiple_args.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_multiple_args.yaml new file mode 100644 index 00000000..d256bf56 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_multiple_args.yaml @@ -0,0 +1,12 @@ +description: Continue with multiple arguments produces an error. +input: + script: |+ + for i in 1; do + continue 1 2 + done +expect: + stdout: "" + stderr_contains: + - "continue" + - "too many arguments" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_outside_loop.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_outside_loop.yaml new file mode 100644 index 00000000..137786f0 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_outside_loop.yaml @@ -0,0 +1,12 @@ +test_against_local_shell: false +description: Continue outside a loop produces an error message. +input: + script: |+ + continue + echo after +expect: + stdout: |+ + after + stderr: |+ + continue is only useful in a loop + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_simple.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_simple.yaml new file mode 100644 index 00000000..d55c98ff --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_simple.yaml @@ -0,0 +1,15 @@ +description: Continue skips the rest of the loop body and goes to the next iteration. +input: + script: |+ + for i in a b c; do + echo "before $i" + continue + echo "after $i" + done +expect: + stdout: |+ + before a + before b + before c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_three_outermost.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_three_outermost.yaml new file mode 100644 index 00000000..37c9f005 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_three_outermost.yaml @@ -0,0 +1,31 @@ +description: Continue 3 in triple nested for loops continues the outermost loop. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + for k in + -; do + echo in $i $j $k + continue 3 + echo out $i $j $k + done + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 1 a + + in 2 + in 2 a + in 2 a + + in 3 + in 3 a + in 3 a + + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_two_in_triple_nested.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_two_in_triple_nested.yaml new file mode 100644 index 00000000..20add637 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_two_in_triple_nested.yaml @@ -0,0 +1,46 @@ +description: Continue 2 in triple nested for loops continues the middle loop, outer still iterates. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + for k in + -; do + echo in $i $j $k + continue 2 + echo out $i $j $k + done + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 1 a + + in 1 b + in 1 b + + in 1 c + in 1 c + + out 1 + in 2 + in 2 a + in 2 a + + in 2 b + in 2 b + + in 2 c + in 2 c + + out 2 + in 3 + in 3 a + in 3 a + + in 3 b + in 3 b + + in 3 c + in 3 c + + out 3 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_two_nested.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_two_nested.yaml new file mode 100644 index 00000000..3b15cd45 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_two_nested.yaml @@ -0,0 +1,28 @@ +description: Continue 2 in triple nested for loop continues the middle loop. +input: + script: |+ + for i in 1 2; do + echo in $i + for j in a b; do + for k in + -; do + echo in $i $j $k + continue 2 + done + echo mid + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + + in 1 b + + out 1 + in 2 + in 2 a + + in 2 b + + out 2 + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_two_outermost.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_two_outermost.yaml new file mode 100644 index 00000000..df438b30 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_two_outermost.yaml @@ -0,0 +1,24 @@ +description: Continue 2 in double nested for loops continues the outer loop. +input: + script: |+ + for i in 1 2 3; do + echo in $i + for j in a b c; do + echo in $i $j + continue 2 + echo out $i $j + done + echo out $i + done + echo done $? +expect: + stdout: |+ + in 1 + in 1 a + in 2 + in 2 a + in 3 + in 3 a + done 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_with_arg.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_with_arg.yaml new file mode 100644 index 00000000..8ec8f2d7 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_with_arg.yaml @@ -0,0 +1,13 @@ +description: Continue with argument 1 continues one level (same as simple continue). +input: + script: |+ + for i in a b c; do + continue 1 + echo $i + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_with_negation.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_with_negation.yaml new file mode 100644 index 00000000..6b9614c1 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_with_negation.yaml @@ -0,0 +1,14 @@ +description: Continue with negation prefix continues the loop with inverted exit code. +input: + script: |+ + for i in 1 2; do + echo $i + ! continue + echo not reached + done +expect: + stdout: |+ + 1 + 2 + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_zero_arg.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_zero_arg.yaml new file mode 100644 index 00000000..788f3268 --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_zero_arg.yaml @@ -0,0 +1,13 @@ +description: Continue 0 is an invalid operand and produces an error. +input: + script: |+ + for i in 1; do + continue 0 + done +expect: + stdout: "" + stderr_contains: + - "continue" + - "0" + - "loop count out of range" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/exit_code/break_preserves_zero_after_false.yaml b/tests/scenarios/shell/for_clause/exit_code/break_preserves_zero_after_false.yaml new file mode 100644 index 00000000..e00291ac --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/break_preserves_zero_after_false.yaml @@ -0,0 +1,10 @@ +description: Exit status of break is 0 even after false. +input: + script: |+ + for i in 1; do false; break; done + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/exit_code/continue_preserves_zero_after_false.yaml b/tests/scenarios/shell/for_clause/exit_code/continue_preserves_zero_after_false.yaml new file mode 100644 index 00000000..f7e1d166 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/continue_preserves_zero_after_false.yaml @@ -0,0 +1,10 @@ +description: Exit status of continue is 0 even after false. +input: + script: |+ + for i in 1; do false; continue; done + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/exit_code/exit_code_after_break.yaml b/tests/scenarios/shell/for_clause/exit_code/exit_code_after_break.yaml new file mode 100644 index 00000000..820b3652 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/exit_code_after_break.yaml @@ -0,0 +1,10 @@ +description: Exit code after break is 0. +input: + script: |+ + for i in a b c; do + break + done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/exit_code/exit_code_body_mixed.yaml b/tests/scenarios/shell/for_clause/exit_code/exit_code_body_mixed.yaml new file mode 100644 index 00000000..94639c00 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/exit_code_body_mixed.yaml @@ -0,0 +1,14 @@ +description: For loop exit code comes from the very last command of the final iteration regardless of earlier commands. +input: + script: |+ + for i in a b c; do + false + echo $i + done +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/exit_code/exit_code_empty_list.yaml b/tests/scenarios/shell/for_clause/exit_code/exit_code_empty_list.yaml new file mode 100644 index 00000000..affb1a34 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/exit_code_empty_list.yaml @@ -0,0 +1,8 @@ +description: For loop with empty list exits with code 0. +input: + script: |+ + for i in; do false; done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/exit_code/exit_code_last_cmd.yaml b/tests/scenarios/shell/for_clause/exit_code/exit_code_last_cmd.yaml new file mode 100644 index 00000000..f5f42415 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/exit_code_last_cmd.yaml @@ -0,0 +1,8 @@ +description: For loop exit code reflects the last command in the body. +input: + script: |+ + for i in a; do false; done +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/exit_code/exit_code_last_iteration.yaml b/tests/scenarios/shell/for_clause/exit_code/exit_code_last_iteration.yaml new file mode 100644 index 00000000..beb2d3e0 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/exit_code_last_iteration.yaml @@ -0,0 +1,15 @@ +description: For loop exit code is from the last command in the last iteration. +input: + script: |+ + for x in 1 2 3; do + true + done + echo $? + for x in 1 2 3; do + false + done +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/for_clause/exit_code/exit_code_preserves_previous.yaml b/tests/scenarios/shell/for_clause/exit_code/exit_code_preserves_previous.yaml new file mode 100644 index 00000000..8d929445 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/exit_code_preserves_previous.yaml @@ -0,0 +1,11 @@ +description: For loop with empty word list has exit status 0 regardless of previous command status. +input: + script: |+ + false + for i in; do echo x; done + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/exit_code/exit_code_success.yaml b/tests/scenarios/shell/for_clause/exit_code/exit_code_success.yaml new file mode 100644 index 00000000..7f420b60 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/exit_code_success.yaml @@ -0,0 +1,10 @@ +description: For loop with successful body exits with code 0. +input: + script: |+ + for i in a b; do echo $i; done +expect: + stdout: |+ + a + b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/exit_code/unknown_cmd_in_body.yaml b/tests/scenarios/shell/for_clause/exit_code/unknown_cmd_in_body.yaml new file mode 100644 index 00000000..cb29f080 --- /dev/null +++ b/tests/scenarios/shell/for_clause/exit_code/unknown_cmd_in_body.yaml @@ -0,0 +1,8 @@ +description: Unknown command in for loop body produces error on stderr. +input: + script: |+ + for i in a; do nonexistent_cmd; done +expect: + stdout: "" + stderr_contains: ["nonexistent_cmd", "command not found"] + exit_code: 127 diff --git a/tests/scenarios/shell/for_clause/nested/basic.yaml b/tests/scenarios/shell/for_clause/nested/basic.yaml new file mode 100644 index 00000000..c14c94de --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/basic.yaml @@ -0,0 +1,16 @@ +description: Nested for loops iterate correctly over all combinations. +input: + script: |+ + for i in a b; do + for j in 1 2; do + echo "$i$j" + done + done +expect: + stdout: |+ + a1 + a2 + b1 + b2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/nested/break_inner.yaml b/tests/scenarios/shell/for_clause/nested/break_inner.yaml new file mode 100644 index 00000000..8be88185 --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/break_inner.yaml @@ -0,0 +1,15 @@ +description: Break in inner nested loop only breaks the inner loop. +input: + script: |+ + for i in a b; do + for j in 1 2 3; do + echo "$i$j" + break + done + done +expect: + stdout: |+ + a1 + b1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/nested/break_outer.yaml b/tests/scenarios/shell/for_clause/nested/break_outer.yaml new file mode 100644 index 00000000..f153da3b --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/break_outer.yaml @@ -0,0 +1,16 @@ +description: Break 2 in inner loop breaks both inner and outer loops. +input: + script: |+ + for i in a b c; do + for j in 1 2 3; do + echo "$i$j" + break 2 + done + done + echo done +expect: + stdout: |+ + a1 + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/nested/break_three_levels.yaml b/tests/scenarios/shell/for_clause/nested/break_three_levels.yaml new file mode 100644 index 00000000..a6de42e8 --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/break_three_levels.yaml @@ -0,0 +1,20 @@ +description: Break 3 in three-level nested loop breaks all loops. +input: + script: |+ + for i in a b; do + for j in 1 2; do + for k in x y; do + echo "$i$j$k" + break 3 + done + echo "inner-done" + done + echo "mid-done" + done + echo done +expect: + stdout: |+ + a1x + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/nested/continue_inner.yaml b/tests/scenarios/shell/for_clause/nested/continue_inner.yaml new file mode 100644 index 00000000..5cce82fb --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/continue_inner.yaml @@ -0,0 +1,16 @@ +description: Continue in inner nested loop only continues the inner loop. +input: + script: |+ + for i in a b; do + for j in 1 2; do + continue + echo "skipped" + done + echo "outer $i" + done +expect: + stdout: |+ + outer a + outer b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/nested/continue_outer.yaml b/tests/scenarios/shell/for_clause/nested/continue_outer.yaml new file mode 100644 index 00000000..5a565f37 --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/continue_outer.yaml @@ -0,0 +1,18 @@ +description: Continue 2 in inner loop continues the outer loop. +input: + script: |+ + for i in a b; do + for j in 1 2 3; do + echo "$i$j" + continue 2 + done + echo "inner-done" + done + echo done +expect: + stdout: |+ + a1 + b1 + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/nested/continue_three_levels.yaml b/tests/scenarios/shell/for_clause/nested/continue_three_levels.yaml new file mode 100644 index 00000000..eba74e3e --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/continue_three_levels.yaml @@ -0,0 +1,21 @@ +description: Continue 3 in three-level nested loop continues the outermost loop. +input: + script: |+ + for i in a b; do + for j in 1 2; do + for k in x y; do + echo "$i$j$k" + continue 3 + done + echo "inner-done" + done + echo "mid-done" + done + echo done +expect: + stdout: |+ + a1x + b1x + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/nested/with_pipe.yaml b/tests/scenarios/shell/for_clause/nested/with_pipe.yaml new file mode 100644 index 00000000..5399095a --- /dev/null +++ b/tests/scenarios/shell/for_clause/nested/with_pipe.yaml @@ -0,0 +1,16 @@ +description: Nested for loops can use pipes to pass output between stages. +input: + script: |+ + for i in a b; do + for j in 1 2; do + echo "$i$j" + done + done | cat +expect: + stdout: |+ + a1 + a2 + b1 + b2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/env_var_in_items.yaml b/tests/scenarios/shell/for_clause/var_scoping/env_var_in_items.yaml new file mode 100644 index 00000000..9d9be16a --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/env_var_in_items.yaml @@ -0,0 +1,14 @@ +description: For loop iterates over items built from variable expansion. +input: + script: |+ + A=one + B=two + C=three + for i in $A $B $C; do echo $i; done +expect: + stdout: |+ + one + two + three + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/loop_var_reused.yaml b/tests/scenarios/shell/for_clause/var_scoping/loop_var_reused.yaml new file mode 100644 index 00000000..fdcf5990 --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/loop_var_reused.yaml @@ -0,0 +1,18 @@ +description: Same loop variable name reused across multiple for loops takes value from last loop. +input: + script: |+ + for i in a b; do echo $i; done + echo "after first: $i" + for i in x y z; do echo $i; done + echo "after second: $i" +expect: + stdout: |+ + a + b + after first: b + x + y + z + after second: z + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/nested_inner_var_visible.yaml b/tests/scenarios/shell/for_clause/var_scoping/nested_inner_var_visible.yaml new file mode 100644 index 00000000..f5a80b36 --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/nested_inner_var_visible.yaml @@ -0,0 +1,14 @@ +description: Loop variable from inner nested for loop is visible after both loops complete. +input: + script: |+ + for i in a b; do + for j in 1 2; do + true + done + done + echo "i=$i j=$j" +expect: + stdout: |+ + i=b j=2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/unset_var_empty_expansion.yaml b/tests/scenarios/shell/for_clause/var_scoping/unset_var_empty_expansion.yaml new file mode 100644 index 00000000..e1bd1b8c --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/unset_var_empty_expansion.yaml @@ -0,0 +1,12 @@ +description: Expanding an unset variable in for loop items produces an empty list. +input: + script: |+ + for i in $UNDEFINED_VAR; do + echo "got: $i" + done + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/var_assigned_in_body.yaml b/tests/scenarios/shell/for_clause/var_scoping/var_assigned_in_body.yaml new file mode 100644 index 00000000..8a83b664 --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/var_assigned_in_body.yaml @@ -0,0 +1,12 @@ +description: Variable assigned in loop body is visible after the loop. +input: + script: |+ + for i in a b c; do + LAST=$i + done + echo $LAST +expect: + stdout: |+ + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/var_expansion_in_items.yaml b/tests/scenarios/shell/for_clause/var_scoping/var_expansion_in_items.yaml new file mode 100644 index 00000000..0e83dd03 --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/var_expansion_in_items.yaml @@ -0,0 +1,12 @@ +description: For loop items can reference variables via expansion. +input: + script: |+ + ITEMS="x y z" + for i in $ITEMS; do echo $i; done +expect: + stdout: |+ + x + y + z + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/var_from_outer_scope.yaml b/tests/scenarios/shell/for_clause/var_scoping/var_from_outer_scope.yaml new file mode 100644 index 00000000..30be4deb --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/var_from_outer_scope.yaml @@ -0,0 +1,10 @@ +description: Variables defined before the loop are accessible inside the body. +input: + script: |+ + PREFIX=hello + for i in world; do echo "$PREFIX $i"; done +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/var_no_clobber_outer.yaml b/tests/scenarios/shell/for_clause/var_scoping/var_no_clobber_outer.yaml new file mode 100644 index 00000000..bd2e125b --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/var_no_clobber_outer.yaml @@ -0,0 +1,13 @@ +description: Loop variable does not clobber a different-named outer variable. +input: + script: |+ + X=original + for i in a b; do echo $i; done + echo $X +expect: + stdout: |+ + a + b + original + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/var_scoping/var_persists_after_loop.yaml b/tests/scenarios/shell/for_clause/var_scoping/var_persists_after_loop.yaml new file mode 100644 index 00000000..fadeeba2 --- /dev/null +++ b/tests/scenarios/shell/for_clause/var_scoping/var_persists_after_loop.yaml @@ -0,0 +1,13 @@ +description: Loop variable retains value from last iteration after loop. +input: + script: |+ + for i in a b c; do echo $i; done + echo "after: $i" +expect: + stdout: |+ + a + b + c + after: c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/character_range.yaml b/tests/scenarios/shell/globbing/bracket/character_range.yaml new file mode 100644 index 00000000..d7f5d804 --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/character_range.yaml @@ -0,0 +1,22 @@ +description: Bracket glob with range matches characters in the range. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.txt + content: "" + - path: d.txt + content: "" + - path: z.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo [a-c].txt +expect: + stdout: |+ + a.txt b.txt c.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/character_set.yaml b/tests/scenarios/shell/globbing/bracket/character_set.yaml new file mode 100644 index 00000000..508ed716 --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/character_set.yaml @@ -0,0 +1,18 @@ +description: Bracket glob matches any single character in the set. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo [ab].txt +expect: + stdout: |+ + a.txt b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/digit_range.yaml b/tests/scenarios/shell/globbing/bracket/digit_range.yaml new file mode 100644 index 00000000..2f3589fc --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/digit_range.yaml @@ -0,0 +1,22 @@ +description: Bracket pattern with digit range matches numeric characters. +setup: + files: + - path: file0.txt + content: "" + - path: file1.txt + content: "" + - path: file5.txt + content: "" + - path: file9.txt + content: "" + - path: filea.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo file[0-4].txt +expect: + stdout: |+ + file0.txt file1.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/multiple_brackets.yaml b/tests/scenarios/shell/globbing/bracket/multiple_brackets.yaml new file mode 100644 index 00000000..12e5239b --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/multiple_brackets.yaml @@ -0,0 +1,20 @@ +description: Bracket pattern matches exactly one character from the set. +setup: + files: + - path: a1.txt + content: "" + - path: b2.txt + content: "" + - path: c3.txt + content: "" + - path: d4.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo [ac][13].txt +expect: + stdout: |+ + a1.txt c3.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/negation.yaml b/tests/scenarios/shell/globbing/bracket/negation.yaml new file mode 100644 index 00000000..bcf20fb6 --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/negation.yaml @@ -0,0 +1,18 @@ +description: Bracket glob with negation excludes specified characters. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo [!a].txt +expect: + stdout: |+ + b.txt c.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/negation_range.yaml b/tests/scenarios/shell/globbing/bracket/negation_range.yaml new file mode 100644 index 00000000..4728e92c --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/negation_range.yaml @@ -0,0 +1,22 @@ +description: Bracket negation with range - [!a-c] matches characters NOT in the range. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.txt + content: "" + - path: d.txt + content: "" + - path: z.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo [!a-c].txt +expect: + stdout: |+ + d.txt z.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/no_match_literal.yaml b/tests/scenarios/shell/globbing/bracket/no_match_literal.yaml new file mode 100644 index 00000000..efe763b3 --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/no_match_literal.yaml @@ -0,0 +1,10 @@ +description: Bracket glob with no matches expands to the literal pattern. +input: + allowed_paths: ["$DIR"] + script: |+ + echo [abc].xyz +expect: + stdout: |+ + [abc].xyz + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/bracket/no_match_literal_range.yaml b/tests/scenarios/shell/globbing/bracket/no_match_literal_range.yaml new file mode 100644 index 00000000..062c74d7 --- /dev/null +++ b/tests/scenarios/shell/globbing/bracket/no_match_literal_range.yaml @@ -0,0 +1,14 @@ +description: Bracket expression that matches no files is treated as a literal string. +setup: + files: + - path: x.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo [abc].log +expect: + stdout: |+ + [abc].log + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/for_loop/iterate_bracket_glob.yaml b/tests/scenarios/shell/globbing/for_loop/iterate_bracket_glob.yaml new file mode 100644 index 00000000..6d909ea8 --- /dev/null +++ b/tests/scenarios/shell/globbing/for_loop/iterate_bracket_glob.yaml @@ -0,0 +1,21 @@ +description: Glob results are used as iteration items in for loop. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + for f in [a-b].txt; do + echo "match:$f" + done +expect: + stdout: |+ + match:a.txt + match:b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/for_loop/iterate_glob.yaml b/tests/scenarios/shell/globbing/for_loop/iterate_glob.yaml new file mode 100644 index 00000000..f9abfcd4 --- /dev/null +++ b/tests/scenarios/shell/globbing/for_loop/iterate_glob.yaml @@ -0,0 +1,20 @@ +description: For loop iterates over glob-expanded filenames. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + for f in *.txt; do echo "file:$f"; done +expect: + stdout: |+ + file:a.txt + file:b.txt + file:c.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/does_not_match_empty.yaml b/tests/scenarios/shell/globbing/question_mark/does_not_match_empty.yaml new file mode 100644 index 00000000..afa4d3aa --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/does_not_match_empty.yaml @@ -0,0 +1,18 @@ +description: Question mark does not match empty string - it requires exactly one character. +setup: + files: + - path: a.txt + content: "" + - path: ab.txt + content: "" + - path: .txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo ?.txt +expect: + stdout: |+ + a.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/mixed_with_star.yaml b/tests/scenarios/shell/globbing/question_mark/mixed_with_star.yaml new file mode 100644 index 00000000..975d0518 --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/mixed_with_star.yaml @@ -0,0 +1,18 @@ +description: Question mark and star can be combined in a pattern. +setup: + files: + - path: a1.txt + content: "" + - path: b2.txt + content: "" + - path: cc.log + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo ?*.txt +expect: + stdout: |+ + a1.txt b2.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/multiple_question_marks.yaml b/tests/scenarios/shell/globbing/question_mark/multiple_question_marks.yaml new file mode 100644 index 00000000..39b1f4be --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/multiple_question_marks.yaml @@ -0,0 +1,20 @@ +description: Multiple question marks match exact number of characters. +setup: + files: + - path: ab.txt + content: "" + - path: cd.txt + content: "" + - path: a.txt + content: "" + - path: abc.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo ??.txt +expect: + stdout: |+ + ab.txt cd.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/no_match_literal.yaml b/tests/scenarios/shell/globbing/question_mark/no_match_literal.yaml new file mode 100644 index 00000000..9b7f19c6 --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/no_match_literal.yaml @@ -0,0 +1,10 @@ +description: Question mark glob with no matches expands to the literal pattern. +input: + allowed_paths: ["$DIR"] + script: |+ + echo ?.xyz +expect: + stdout: |+ + ?.xyz + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/question_then_star.yaml b/tests/scenarios/shell/globbing/question_mark/question_then_star.yaml new file mode 100644 index 00000000..3ba1eb82 --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/question_then_star.yaml @@ -0,0 +1,20 @@ +description: Glob patterns combined with question mark and star work together. +setup: + files: + - path: a1.txt + content: "" + - path: ab2.txt + content: "" + - path: abc3.txt + content: "" + - path: b1.log + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo ?*.txt +expect: + stdout: |+ + a1.txt ab2.txt abc3.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/single_char_match.yaml b/tests/scenarios/shell/globbing/question_mark/single_char_match.yaml new file mode 100644 index 00000000..9067ac88 --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/single_char_match.yaml @@ -0,0 +1,18 @@ +description: Question mark glob matches exactly one character. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: ab.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo ?.txt +expect: + stdout: |+ + a.txt b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/star_then_question.yaml b/tests/scenarios/shell/globbing/question_mark/star_then_question.yaml new file mode 100644 index 00000000..c6947a1a --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/star_then_question.yaml @@ -0,0 +1,18 @@ +description: Star followed by question mark matches files with at least one character. +setup: + files: + - path: a.txt + content: "" + - path: ab.txt + content: "" + - path: .txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo *?.txt +expect: + stdout: |+ + a.txt ab.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/question_mark/three_question_marks.yaml b/tests/scenarios/shell/globbing/question_mark/three_question_marks.yaml new file mode 100644 index 00000000..c2f180a9 --- /dev/null +++ b/tests/scenarios/shell/globbing/question_mark/three_question_marks.yaml @@ -0,0 +1,18 @@ +description: Three question marks match exactly three-character filenames. +setup: + files: + - path: ab.txt + content: "" + - path: abc.txt + content: "" + - path: abcd.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo ???.txt +expect: + stdout: |+ + abc.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/quoting/double_quoted_var_no_glob.yaml b/tests/scenarios/shell/globbing/quoting/double_quoted_var_no_glob.yaml new file mode 100644 index 00000000..2992c582 --- /dev/null +++ b/tests/scenarios/shell/globbing/quoting/double_quoted_var_no_glob.yaml @@ -0,0 +1,17 @@ +description: Variable containing glob characters is NOT glob-matched when double-quoted. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + PATTERN="*.txt" + echo "$PATTERN" +expect: + stdout: |+ + *.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/quoting/double_quotes_no_expand.yaml b/tests/scenarios/shell/globbing/quoting/double_quotes_no_expand.yaml new file mode 100644 index 00000000..b47acbdb --- /dev/null +++ b/tests/scenarios/shell/globbing/quoting/double_quotes_no_expand.yaml @@ -0,0 +1,13 @@ +description: Glob inside double quotes is treated as literal. +setup: + files: + - path: a.txt + content: "" +input: + script: |+ + echo "*.txt" +expect: + stdout: |+ + *.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/quoting/single_quotes_no_expand.yaml b/tests/scenarios/shell/globbing/quoting/single_quotes_no_expand.yaml new file mode 100644 index 00000000..0b73d36e --- /dev/null +++ b/tests/scenarios/shell/globbing/quoting/single_quotes_no_expand.yaml @@ -0,0 +1,13 @@ +description: Glob inside single quotes is treated as literal. +setup: + files: + - path: a.txt + content: "" +input: + script: |+ + echo '*.txt' +expect: + stdout: |+ + *.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/quoting/unquoted_var_glob_expands.yaml b/tests/scenarios/shell/globbing/quoting/unquoted_var_glob_expands.yaml new file mode 100644 index 00000000..174a426f --- /dev/null +++ b/tests/scenarios/shell/globbing/quoting/unquoted_var_glob_expands.yaml @@ -0,0 +1,19 @@ +description: Variable containing glob characters is expanded and glob-matched when unquoted. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.log + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + PATTERN="*.txt" + echo $PATTERN +expect: + stdout: |+ + a.txt b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/all_files.yaml b/tests/scenarios/shell/globbing/star/all_files.yaml new file mode 100644 index 00000000..52864b9c --- /dev/null +++ b/tests/scenarios/shell/globbing/star/all_files.yaml @@ -0,0 +1,17 @@ +description: Star glob matches all non-hidden files. +setup: + files: + - path: a.txt + content: "" + - path: b.log + content: "" + - path: c + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo * +expect: + stdout_contains: ["a.txt", "b.log", "c"] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/basic_match.yaml b/tests/scenarios/shell/globbing/star/basic_match.yaml new file mode 100644 index 00000000..a3b68d42 --- /dev/null +++ b/tests/scenarios/shell/globbing/star/basic_match.yaml @@ -0,0 +1,18 @@ +description: Star glob expands to matching files. +setup: + files: + - path: foo.txt + content: "" + - path: bar.txt + content: "" + - path: baz.log + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo *.txt +expect: + stdout: |+ + bar.txt foo.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/double_star_same_as_single.yaml b/tests/scenarios/shell/globbing/star/double_star_same_as_single.yaml new file mode 100644 index 00000000..f5811d83 --- /dev/null +++ b/tests/scenarios/shell/globbing/star/double_star_same_as_single.yaml @@ -0,0 +1,16 @@ +description: Double asterisk behaves the same as single asterisk (no recursive globbing). +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo **.txt +expect: + stdout: |+ + a.txt b.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/in_subdirectory.yaml b/tests/scenarios/shell/globbing/star/in_subdirectory.yaml new file mode 100644 index 00000000..87eff2af --- /dev/null +++ b/tests/scenarios/shell/globbing/star/in_subdirectory.yaml @@ -0,0 +1,17 @@ +description: Star glob matches files in a subdirectory. +target_os: [linux, darwin] +setup: + files: + - path: sub/x.txt + content: "" + - path: sub/y.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo sub/*.txt +expect: + stdout: |+ + sub/x.txt sub/y.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/in_subdirectory_windows.yaml b/tests/scenarios/shell/globbing/star/in_subdirectory_windows.yaml new file mode 100644 index 00000000..fde6915d --- /dev/null +++ b/tests/scenarios/shell/globbing/star/in_subdirectory_windows.yaml @@ -0,0 +1,17 @@ +description: Star glob matches files in a subdirectory (Windows paths). +target_os: [windows] +setup: + files: + - path: sub/x.txt + content: "" + - path: sub/y.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo sub/*.txt +expect: + stdout: |+ + sub\x.txt sub\y.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/matches_directories.yaml b/tests/scenarios/shell/globbing/star/matches_directories.yaml new file mode 100644 index 00000000..aafbe358 --- /dev/null +++ b/tests/scenarios/shell/globbing/star/matches_directories.yaml @@ -0,0 +1,15 @@ +description: Star glob matches directories alongside files. +setup: + files: + - path: dir/placeholder + content: "" + - path: file.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo * +expect: + stdout_contains: ["dir", "file.txt"] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/matches_empty_prefix.yaml b/tests/scenarios/shell/globbing/star/matches_empty_prefix.yaml new file mode 100644 index 00000000..3c3ea4a7 --- /dev/null +++ b/tests/scenarios/shell/globbing/star/matches_empty_prefix.yaml @@ -0,0 +1,16 @@ +description: Asterisk matches empty string before suffix, so *.txt matches a file named just the suffix. +setup: + files: + - path: a.txt + content: "" + - path: bb.txt + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo *.txt +expect: + stdout: |+ + a.txt bb.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/multiple_patterns.yaml b/tests/scenarios/shell/globbing/star/multiple_patterns.yaml new file mode 100644 index 00000000..c1a8e78e --- /dev/null +++ b/tests/scenarios/shell/globbing/star/multiple_patterns.yaml @@ -0,0 +1,20 @@ +description: Multiple glob patterns on one command line each expand independently. +setup: + files: + - path: a.txt + content: "" + - path: b.txt + content: "" + - path: c.log + content: "" + - path: d.log + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo *.txt *.log +expect: + stdout: |+ + a.txt b.txt c.log d.log + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/no_match_literal.yaml b/tests/scenarios/shell/globbing/star/no_match_literal.yaml new file mode 100644 index 00000000..57eaeb9f --- /dev/null +++ b/tests/scenarios/shell/globbing/star/no_match_literal.yaml @@ -0,0 +1,10 @@ +description: Star glob with no matches expands to the literal pattern. +input: + allowed_paths: ["$DIR"] + script: |+ + echo *.xyz +expect: + stdout: |+ + *.xyz + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/prefix_and_suffix.yaml b/tests/scenarios/shell/globbing/star/prefix_and_suffix.yaml new file mode 100644 index 00000000..9d0ec2f2 --- /dev/null +++ b/tests/scenarios/shell/globbing/star/prefix_and_suffix.yaml @@ -0,0 +1,20 @@ +description: Asterisk with both prefix and suffix matches files with that pattern. +setup: + files: + - path: test_one.sh + content: "" + - path: test_two.sh + content: "" + - path: test_three.go + content: "" + - path: run_one.sh + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo test_*.sh +expect: + stdout: |+ + test_one.sh test_two.sh + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/prefix_match.yaml b/tests/scenarios/shell/globbing/star/prefix_match.yaml new file mode 100644 index 00000000..b678a34a --- /dev/null +++ b/tests/scenarios/shell/globbing/star/prefix_match.yaml @@ -0,0 +1,18 @@ +description: Star glob with prefix matches files starting with that prefix. +setup: + files: + - path: test_one.sh + content: "" + - path: test_two.sh + content: "" + - path: other.sh + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo test_* +expect: + stdout: |+ + test_one.sh test_two.sh + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/skips_dotfiles.yaml b/tests/scenarios/shell/globbing/star/skips_dotfiles.yaml new file mode 100644 index 00000000..00e6bf6b --- /dev/null +++ b/tests/scenarios/shell/globbing/star/skips_dotfiles.yaml @@ -0,0 +1,16 @@ +description: Star glob does not match dotfiles. +setup: + files: + - path: visible.txt + content: "" + - path: .hidden + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo * +expect: + stdout: |+ + visible.txt + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/star_alone.yaml b/tests/scenarios/shell/globbing/star/star_alone.yaml new file mode 100644 index 00000000..05843d6e --- /dev/null +++ b/tests/scenarios/shell/globbing/star/star_alone.yaml @@ -0,0 +1,18 @@ +description: Asterisk alone matches all non-hidden files and directories in the current directory. +setup: + files: + - path: file1.txt + content: "" + - path: file2.log + content: "" + - path: .hidden + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo * +expect: + stdout: |+ + file1.txt file2.log + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/globbing/star/suffix_match.yaml b/tests/scenarios/shell/globbing/star/suffix_match.yaml new file mode 100644 index 00000000..f463b491 --- /dev/null +++ b/tests/scenarios/shell/globbing/star/suffix_match.yaml @@ -0,0 +1,18 @@ +description: Asterisk with suffix pattern matches files ending with the suffix. +setup: + files: + - path: readme.md + content: "" + - path: notes.md + content: "" + - path: code.go + content: "" +input: + allowed_paths: ["$DIR"] + script: |+ + echo *.md +expect: + stdout: |+ + notes.md readme.md + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/heredoc/and_logic.yaml b/tests/scenarios/shell/heredoc/and_logic.yaml new file mode 100644 index 00000000..a632b369 --- /dev/null +++ b/tests/scenarios/shell/heredoc/and_logic.yaml @@ -0,0 +1,12 @@ +description: Heredoc combined with && runs the next command when cat succeeds. +input: + script: |+ + cat <- + When the command word expands to nothing (unquoted unset variable), + the assignment becomes persistent (inspired by yash simple-p.tst). +input: + script: |+ + A=hello $___NONEXISTENT_VAR___ + echo $A +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/inline_var/restore_after.yaml b/tests/scenarios/shell/inline_var/restore_after.yaml new file mode 100644 index 00000000..63947896 --- /dev/null +++ b/tests/scenarios/shell/inline_var/restore_after.yaml @@ -0,0 +1,12 @@ +description: Inline variable assignment restores the previous value after the command completes. +input: + script: |+ + VAR=original + VAR=temporary echo anything + echo $VAR +expect: + stdout: |+ + anything + original + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/across_and_operator.yaml b/tests/scenarios/shell/line_continuation/across_and_operator.yaml new file mode 100644 index 00000000..c44c9fd7 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/across_and_operator.yaml @@ -0,0 +1,10 @@ +description: Line continuation across operators && joins the command correctly. +input: + script: |+ + true \ + && echo ok +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/across_or_operator.yaml b/tests/scenarios/shell/line_continuation/across_or_operator.yaml new file mode 100644 index 00000000..35bb6cbe --- /dev/null +++ b/tests/scenarios/shell/line_continuation/across_or_operator.yaml @@ -0,0 +1,10 @@ +description: Line continuation across operator || joins the command correctly. +input: + script: |+ + false \ + || echo ok +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/across_pipe.yaml b/tests/scenarios/shell/line_continuation/across_pipe.yaml new file mode 100644 index 00000000..8538b280 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/across_pipe.yaml @@ -0,0 +1,10 @@ +description: Line continuation across a pipe operator joins the pipeline correctly. +input: + script: |+ + echo hello \ + | cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/basic.yaml b/tests/scenarios/shell/line_continuation/basic.yaml new file mode 100644 index 00000000..b20e32de --- /dev/null +++ b/tests/scenarios/shell/line_continuation/basic.yaml @@ -0,0 +1,10 @@ +description: Backslash at end of line continues the command on the next line. +input: + script: |+ + echo hel\ + lo +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/empty_continuation_line.yaml b/tests/scenarios/shell/line_continuation/empty_continuation_line.yaml new file mode 100644 index 00000000..c5f48432 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/empty_continuation_line.yaml @@ -0,0 +1,11 @@ +description: Line continuation with empty continuation line (just backslash-newline-backslash-newline). +input: + script: |+ + echo 123\ + \ + 456 +expect: + stdout: |+ + 123456 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_and_operator.yaml b/tests/scenarios/shell/line_continuation/in_and_operator.yaml new file mode 100644 index 00000000..a4aba6db --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_and_operator.yaml @@ -0,0 +1,14 @@ +description: Line continuation after && operator continues the and-list on the next line. +input: + script: |+ + echo 1 && + echo 2 && + + echo 3 +expect: + stdout: |+ + 1 + 2 + 3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_assignment.yaml b/tests/scenarios/shell/line_continuation/in_assignment.yaml new file mode 100644 index 00000000..0a0ee5bd --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_assignment.yaml @@ -0,0 +1,11 @@ +description: Line continuation in variable assignment joins name and value across lines. +input: + script: |+ + fo\ + o=bar + echo $foo +expect: + stdout: |+ + bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_assignment_then_echo.yaml b/tests/scenarios/shell/line_continuation/in_assignment_then_echo.yaml new file mode 100644 index 00000000..cbcd9d78 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_assignment_then_echo.yaml @@ -0,0 +1,10 @@ +description: Line continuation across assignment and semicolon with echo. +input: + script: |+ + A=he\ + llo; echo $A +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_assignment_value.yaml b/tests/scenarios/shell/line_continuation/in_assignment_value.yaml new file mode 100644 index 00000000..cf82cb11 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_assignment_value.yaml @@ -0,0 +1,14 @@ +description: Line continuation works in variable assignment value. +input: + script: |+ + fo\ + o\ + =\ + b\ + ar + echo $foo +expect: + stdout: |+ + bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_assignment_value_simple.yaml b/tests/scenarios/shell/line_continuation/in_assignment_value_simple.yaml new file mode 100644 index 00000000..cc9df5b5 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_assignment_value_simple.yaml @@ -0,0 +1,11 @@ +description: Line continuation in the value part of a variable assignment. +input: + script: |+ + X=hel\ + lo + echo $X +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_brace_keywords.yaml b/tests/scenarios/shell/line_continuation/in_brace_keywords.yaml new file mode 100644 index 00000000..23b1fac6 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_brace_keywords.yaml @@ -0,0 +1,14 @@ +description: Line continuation works in brace group delimiters. +input: + script: |+ + \ + {\ + echo 1 + \ + }\ + +expect: + stdout: |+ + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_command_name.yaml b/tests/scenarios/shell/line_continuation/in_command_name.yaml new file mode 100644 index 00000000..79878b12 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_command_name.yaml @@ -0,0 +1,10 @@ +description: Line continuation in the middle of the echo command name itself. +input: + script: |+ + ec\ + ho hello +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_echo_args.yaml b/tests/scenarios/shell/line_continuation/in_echo_args.yaml new file mode 100644 index 00000000..bf954085 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_echo_args.yaml @@ -0,0 +1,11 @@ +description: Line continuation between echo arguments continues on next line. +input: + script: |+ + echo foo \ + bar \ + baz +expect: + stdout: |+ + foo bar baz + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_for_item_list.yaml b/tests/scenarios/shell/line_continuation/in_for_item_list.yaml new file mode 100644 index 00000000..8a2df510 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_for_item_list.yaml @@ -0,0 +1,17 @@ +description: Multiple line continuations in for loop item list. +input: + script: |+ + for i in a \ + b \ + c \ + d; do + echo $i + done +expect: + stdout: |+ + a + b + c + d + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_for_keywords.yaml b/tests/scenarios/shell/line_continuation/in_for_keywords.yaml new file mode 100644 index 00000000..1dce2b85 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_for_keywords.yaml @@ -0,0 +1,17 @@ +description: Line continuation works across for loop keywords. +input: + script: |+ + f\ + or i in 1 2 + d\ + o echo $i + d\ + o\ + n\ + e +expect: + stdout: |+ + 1 + 2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_or_operator.yaml b/tests/scenarios/shell/line_continuation/in_or_operator.yaml new file mode 100644 index 00000000..9787b66c --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_or_operator.yaml @@ -0,0 +1,12 @@ +description: Line continuation after || operator continues the or-list on the next line. +input: + script: |+ + false || + false || + + echo fallback +expect: + stdout: |+ + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_pipe_operator.yaml b/tests/scenarios/shell/line_continuation/in_pipe_operator.yaml new file mode 100644 index 00000000..35f4f712 --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_pipe_operator.yaml @@ -0,0 +1,10 @@ +description: Line continuation after pipe operator continues the pipeline on the next line. +input: + script: |+ + echo hello | + cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/in_variable_name.yaml b/tests/scenarios/shell/line_continuation/in_variable_name.yaml new file mode 100644 index 00000000..6e34928b --- /dev/null +++ b/tests/scenarios/shell/line_continuation/in_variable_name.yaml @@ -0,0 +1,11 @@ +description: Line continuation in the middle of a variable name in assignment. +input: + script: |+ + MY\ + VAR=hello + echo $MYVAR +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/inside_double_quotes.yaml b/tests/scenarios/shell/line_continuation/inside_double_quotes.yaml new file mode 100644 index 00000000..ebc035fc --- /dev/null +++ b/tests/scenarios/shell/line_continuation/inside_double_quotes.yaml @@ -0,0 +1,11 @@ +description: Line continuation inside double quotes joins lines without adding space. +input: + script: |+ + echo "hel\ + lo \ + world" +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/line_continuation/multiple_consecutive.yaml b/tests/scenarios/shell/line_continuation/multiple_consecutive.yaml new file mode 100644 index 00000000..1d28443a --- /dev/null +++ b/tests/scenarios/shell/line_continuation/multiple_consecutive.yaml @@ -0,0 +1,11 @@ +description: Multiple consecutive line continuations join several lines into one word. +input: + script: |+ + echo hel\ + l\ + o +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/both_succeed.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/both_succeed.yaml new file mode 100644 index 00000000..44c7df2b --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/both_succeed.yaml @@ -0,0 +1,10 @@ +description: Both commands succeed with && operator. +input: + script: |+ + echo first && echo second +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_all_succeed.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_all_succeed.yaml new file mode 100644 index 00000000..ee718b13 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_all_succeed.yaml @@ -0,0 +1,11 @@ +description: A chain of && commands all succeed and all execute. +input: + script: |+ + echo one && echo two && echo three +expect: + stdout: |+ + one + two + three + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_fails_at_fourth.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_fails_at_fourth.yaml new file mode 100644 index 00000000..a41112cd --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_fails_at_fourth.yaml @@ -0,0 +1,11 @@ +description: A && chain stops at the fourth command when it fails. +input: + script: |+ + echo one && echo two && echo three && false && echo five +expect: + stdout: |+ + one + two + three + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_five_succeed.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_five_succeed.yaml new file mode 100644 index 00000000..a2e47254 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_five_succeed.yaml @@ -0,0 +1,13 @@ +description: A long && chain of five commands succeeds when all succeed. +input: + script: |+ + echo one && echo two && echo three && echo four && echo five +expect: + stdout: |+ + one + two + three + four + five + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_middle_fails.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_middle_fails.yaml new file mode 100644 index 00000000..7bf1d900 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/chain_middle_fails.yaml @@ -0,0 +1,9 @@ +description: A && chain stops when a middle command fails. +input: + script: |+ + echo one && false && echo three +expect: + stdout: |+ + one + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/first_fails_skips_rest.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/first_fails_skips_rest.yaml new file mode 100644 index 00000000..4acf6eb6 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/first_fails_skips_rest.yaml @@ -0,0 +1,8 @@ +description: When first command in a long && chain fails, all subsequent commands are skipped. +input: + script: |+ + false && echo two && echo three && echo four +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/left_fails.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/left_fails.yaml new file mode 100644 index 00000000..a1c33faf --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/left_fails.yaml @@ -0,0 +1,8 @@ +description: When left side of && fails, right side is skipped. +input: + script: |+ + false && echo skipped +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/right_fails.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/right_fails.yaml new file mode 100644 index 00000000..34d932e3 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/right_fails.yaml @@ -0,0 +1,9 @@ +description: When left side succeeds but right side fails, exit code is from right. +input: + script: |+ + echo ok && false +expect: + stdout: |+ + ok + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/with_assignment.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/with_assignment.yaml new file mode 100644 index 00000000..f446beae --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/with_assignment.yaml @@ -0,0 +1,9 @@ +description: Variable set in an assignment command is visible on the right side of &&. +input: + script: |+ + X=hello && echo $X +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/and_basic_behavior/with_true.yaml b/tests/scenarios/shell/logic_ops/and_basic_behavior/with_true.yaml new file mode 100644 index 00000000..184ee155 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/and_basic_behavior/with_true.yaml @@ -0,0 +1,9 @@ +description: The true builtin succeeds so && continues. +input: + script: |+ + true && echo reached +expect: + stdout: |+ + reached + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/exit_code/and_custom_exit_codes.yaml b/tests/scenarios/shell/logic_ops/exit_code/and_custom_exit_codes.yaml new file mode 100644 index 00000000..d166715c --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/and_custom_exit_codes.yaml @@ -0,0 +1,8 @@ +description: Custom exit codes propagate correctly through && chains. +input: + script: |+ + exit 2 && echo skipped +expect: + stdout: "" + stderr: "" + exit_code: 2 diff --git a/tests/scenarios/shell/logic_ops/exit_code/and_exit_code_from_right.yaml b/tests/scenarios/shell/logic_ops/exit_code/and_exit_code_from_right.yaml new file mode 100644 index 00000000..9ad48ae4 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/and_exit_code_from_right.yaml @@ -0,0 +1,8 @@ +description: When both sides of && run, exit code is from the right. +input: + script: |+ + true && exit 7 +expect: + stdout: "" + stderr: "" + exit_code: 7 diff --git a/tests/scenarios/shell/logic_ops/exit_code/and_preserves_left_exit_code.yaml b/tests/scenarios/shell/logic_ops/exit_code/and_preserves_left_exit_code.yaml new file mode 100644 index 00000000..ec53c0a1 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/and_preserves_left_exit_code.yaml @@ -0,0 +1,8 @@ +description: The && operator preserves the failing left exit code. +input: + script: |+ + exit 42 && echo skipped +expect: + stdout: "" + stderr: "" + exit_code: 42 diff --git a/tests/scenarios/shell/logic_ops/exit_code/last_executed_pipeline.yaml b/tests/scenarios/shell/logic_ops/exit_code/last_executed_pipeline.yaml new file mode 100644 index 00000000..ac03d6ad --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/last_executed_pipeline.yaml @@ -0,0 +1,10 @@ +description: Exit status is from the last executed pipeline in an and-or list. +input: + script: |+ + false && exit 1 || true || exit 1 + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/exit_code/mixed_exit_code_recovery.yaml b/tests/scenarios/shell/logic_ops/exit_code/mixed_exit_code_recovery.yaml new file mode 100644 index 00000000..c734076f --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/mixed_exit_code_recovery.yaml @@ -0,0 +1,8 @@ +description: The exit builtin terminates the script immediately so || cannot recover. +input: + script: |+ + exit 99 || true +expect: + stdout: "" + stderr: "" + exit_code: 99 diff --git a/tests/scenarios/shell/logic_ops/exit_code/or_both_custom_exit.yaml b/tests/scenarios/shell/logic_ops/exit_code/or_both_custom_exit.yaml new file mode 100644 index 00000000..4cd79d48 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/or_both_custom_exit.yaml @@ -0,0 +1,8 @@ +description: The exit builtin terminates the script immediately so || right side never runs. +input: + script: |+ + exit 2 || exit 5 +expect: + stdout: "" + stderr: "" + exit_code: 2 diff --git a/tests/scenarios/shell/logic_ops/exit_code/or_exit_code_from_right.yaml b/tests/scenarios/shell/logic_ops/exit_code/or_exit_code_from_right.yaml new file mode 100644 index 00000000..14e67031 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/or_exit_code_from_right.yaml @@ -0,0 +1,8 @@ +description: When left fails in ||, exit code is from the right side. +input: + script: |+ + false || exit 3 +expect: + stdout: "" + stderr: "" + exit_code: 3 diff --git a/tests/scenarios/shell/logic_ops/exit_code/or_exit_code_left_success.yaml b/tests/scenarios/shell/logic_ops/exit_code/or_exit_code_left_success.yaml new file mode 100644 index 00000000..4e325f5f --- /dev/null +++ b/tests/scenarios/shell/logic_ops/exit_code/or_exit_code_left_success.yaml @@ -0,0 +1,8 @@ +description: When left succeeds in ||, exit code is from the left side. +input: + script: |+ + true || exit 5 +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/all_fail_chain.yaml b/tests/scenarios/shell/logic_ops/mixed/all_fail_chain.yaml new file mode 100644 index 00000000..a36aec2d --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/all_fail_chain.yaml @@ -0,0 +1,8 @@ +description: Mixed chain where every path fails. +input: + script: |+ + false && echo no || false && echo no +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/mixed/and_or_and_chain.yaml b/tests/scenarios/shell/logic_ops/mixed/and_or_and_chain.yaml new file mode 100644 index 00000000..1365a4ae --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/and_or_and_chain.yaml @@ -0,0 +1,10 @@ +description: A complex chain mixing && and || operators. +input: + script: |+ + true && false || echo recovered && echo final +expect: + stdout: |+ + recovered + final + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/and_then_or_failure.yaml b/tests/scenarios/shell/logic_ops/mixed/and_then_or_failure.yaml new file mode 100644 index 00000000..de593d6b --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/and_then_or_failure.yaml @@ -0,0 +1,9 @@ +description: Failed && triggers the || fallback. +input: + script: |+ + false && echo skipped || echo fallback +expect: + stdout: |+ + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/and_then_or_success.yaml b/tests/scenarios/shell/logic_ops/mixed/and_then_or_success.yaml new file mode 100644 index 00000000..119fe447 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/and_then_or_success.yaml @@ -0,0 +1,10 @@ +description: Successful && followed by || skips the fallback. +input: + script: |+ + echo ok && echo done || echo fallback +expect: + stdout: |+ + ok + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/brace_left_operand_fails.yaml b/tests/scenarios/shell/logic_ops/mixed/brace_left_operand_fails.yaml new file mode 100644 index 00000000..cf89088c --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/brace_left_operand_fails.yaml @@ -0,0 +1,10 @@ +description: Brace group as the left operand of && fails, right side is skipped. +input: + script: |+ + { false; } && echo skipped + echo done +expect: + stdout: |+ + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/long_alternating_chain.yaml b/tests/scenarios/shell/logic_ops/mixed/long_alternating_chain.yaml new file mode 100644 index 00000000..b090efa8 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/long_alternating_chain.yaml @@ -0,0 +1,10 @@ +description: A long alternating chain of six operators evaluates left to right. +input: + script: |+ + true && false || true && echo step4 || echo step5 && echo step6 +expect: + stdout: |+ + step4 + step6 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/multiple_independent_lists.yaml b/tests/scenarios/shell/logic_ops/mixed/multiple_independent_lists.yaml new file mode 100644 index 00000000..cdd7030a --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/multiple_independent_lists.yaml @@ -0,0 +1,15 @@ +description: Multiple independent and-or lists on separate lines. +input: + script: |+ + true && echo "line1" + false || echo "line2" + true && false || echo "line3" + false && echo "skip" || true && echo "line4" +expect: + stdout: |+ + line1 + line2 + line3 + line4 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/or_and_or_chain.yaml b/tests/scenarios/shell/logic_ops/mixed/or_and_or_chain.yaml new file mode 100644 index 00000000..987b524a --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/or_and_or_chain.yaml @@ -0,0 +1,9 @@ +description: Alternating || and && operators evaluate left to right. +input: + script: |+ + false || true && echo yes || echo no +expect: + stdout: |+ + yes + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/or_then_and_failure.yaml b/tests/scenarios/shell/logic_ops/mixed/or_then_and_failure.yaml new file mode 100644 index 00000000..db584868 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/or_then_and_failure.yaml @@ -0,0 +1,10 @@ +description: Failed || fallback result feeds into &&. +input: + script: |+ + false || echo recovered && echo continued +expect: + stdout: |+ + recovered + continued + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/or_then_and_success.yaml b/tests/scenarios/shell/logic_ops/mixed/or_then_and_success.yaml new file mode 100644 index 00000000..6efc3733 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/or_then_and_success.yaml @@ -0,0 +1,10 @@ +description: Successful || left side followed by && continues. +input: + script: |+ + echo ok || echo fallback && echo continued +expect: + stdout: |+ + ok + continued + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/pipelines_in_list.yaml b/tests/scenarios/shell/logic_ops/mixed/pipelines_in_list.yaml new file mode 100644 index 00000000..2fdf1572 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/pipelines_in_list.yaml @@ -0,0 +1,9 @@ +description: Pipelines with negation in and-or lists. +input: + script: |+ + ! false && ! true | false && echo foo | cat +expect: + stdout: |+ + foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/recovery_with_braces.yaml b/tests/scenarios/shell/logic_ops/mixed/recovery_with_braces.yaml new file mode 100644 index 00000000..12bb8cdd --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/recovery_with_braces.yaml @@ -0,0 +1,10 @@ +description: Error recovery pattern using brace group after || operator. +input: + script: |+ + false || { echo "recovered"; true; } && echo "continued" +expect: + stdout: |+ + recovered + continued + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/semicolons_separate_lists.yaml b/tests/scenarios/shell/logic_ops/mixed/semicolons_separate_lists.yaml new file mode 100644 index 00000000..23b3f531 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/semicolons_separate_lists.yaml @@ -0,0 +1,10 @@ +description: Semicolons separate independent and-or lists on the same line. +input: + script: |+ + false && echo no; true && echo yes; false || echo fallback +expect: + stdout: |+ + yes + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/mixed/three_cmd_fallback.yaml b/tests/scenarios/shell/logic_ops/mixed/three_cmd_fallback.yaml new file mode 100644 index 00000000..117b5195 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/mixed/three_cmd_fallback.yaml @@ -0,0 +1,11 @@ +description: Three-command and-or list with fallback behavior. +input: + script: |+ + false && echo foo || echo bar + true || echo foo && echo bar +expect: + stdout: |+ + bar + bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/both_fail.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/both_fail.yaml new file mode 100644 index 00000000..16e2058f --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/both_fail.yaml @@ -0,0 +1,8 @@ +description: When both sides of || fail, exit code is from right side. +input: + script: |+ + false || false +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_all_fail.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_all_fail.yaml new file mode 100644 index 00000000..5338556d --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_all_fail.yaml @@ -0,0 +1,8 @@ +description: In a || chain, when all commands fail the exit code is from the last. +input: + script: |+ + false || false || false +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_first_succeeds.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_first_succeeds.yaml new file mode 100644 index 00000000..f2d3b8f9 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_first_succeeds.yaml @@ -0,0 +1,9 @@ +description: In a || chain, when first command succeeds the rest are skipped. +input: + script: |+ + echo one || echo two || echo three +expect: + stdout: |+ + one + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_five_all_fail.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_five_all_fail.yaml new file mode 100644 index 00000000..f7776882 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_five_all_fail.yaml @@ -0,0 +1,8 @@ +description: A long || chain of five commands all fail with exit code from the last. +input: + script: |+ + false || false || false || false || false +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_last_succeeds.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_last_succeeds.yaml new file mode 100644 index 00000000..05baa7d9 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_last_succeeds.yaml @@ -0,0 +1,9 @@ +description: In a || chain, commands execute until one succeeds. +input: + script: |+ + false || false || echo reached +expect: + stdout: |+ + reached + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_middle_succeeds.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_middle_succeeds.yaml new file mode 100644 index 00000000..10f98162 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/chain_middle_succeeds.yaml @@ -0,0 +1,9 @@ +description: In a || chain the middle command succeeds so the rest are skipped. +input: + script: |+ + false || echo middle || echo skipped +expect: + stdout: |+ + middle + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/left_fails.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/left_fails.yaml new file mode 100644 index 00000000..7f96ec15 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/left_fails.yaml @@ -0,0 +1,9 @@ +description: When left side of || fails, right side executes. +input: + script: |+ + false || echo fallback +expect: + stdout: |+ + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/left_succeeds.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/left_succeeds.yaml new file mode 100644 index 00000000..372208f1 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/left_succeeds.yaml @@ -0,0 +1,9 @@ +description: When left side of || succeeds, right side is skipped. +input: + script: |+ + echo ok || echo skipped +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/with_assignment.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/with_assignment.yaml new file mode 100644 index 00000000..9e026473 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/with_assignment.yaml @@ -0,0 +1,10 @@ +description: Variable set in left side of || is visible in fallback. +input: + script: |+ + X=hello || echo $X + echo $X +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/or_basic_behavior/with_true.yaml b/tests/scenarios/shell/logic_ops/or_basic_behavior/with_true.yaml new file mode 100644 index 00000000..f4f457a2 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/or_basic_behavior/with_true.yaml @@ -0,0 +1,8 @@ +description: The true builtin succeeds so || skips the right side. +input: + script: |+ + true || echo skipped +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/output/and_no_output_on_skip.yaml b/tests/scenarios/shell/logic_ops/output/and_no_output_on_skip.yaml new file mode 100644 index 00000000..9a8d27c3 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/output/and_no_output_on_skip.yaml @@ -0,0 +1,9 @@ +description: Skipped commands after failed && produce no output. +input: + script: |+ + echo before && false && echo skipped +expect: + stdout: |+ + before + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/logic_ops/output/and_output_accumulates.yaml b/tests/scenarios/shell/logic_ops/output/and_output_accumulates.yaml new file mode 100644 index 00000000..6491aa9e --- /dev/null +++ b/tests/scenarios/shell/logic_ops/output/and_output_accumulates.yaml @@ -0,0 +1,11 @@ +description: Output accumulates across && when both sides succeed. +input: + script: |+ + echo first && echo second && echo third +expect: + stdout: |+ + first + second + third + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/output/linebreak_after_and.yaml b/tests/scenarios/shell/logic_ops/output/linebreak_after_and.yaml new file mode 100644 index 00000000..eb0f7e26 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/output/linebreak_after_and.yaml @@ -0,0 +1,14 @@ +description: Linebreak after && continues the and-or list. +input: + script: |+ + echo 1 && + echo 2 && + + echo 3 +expect: + stdout: |+ + 1 + 2 + 3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/output/linebreak_after_or.yaml b/tests/scenarios/shell/logic_ops/output/linebreak_after_or.yaml new file mode 100644 index 00000000..48c1f43a --- /dev/null +++ b/tests/scenarios/shell/logic_ops/output/linebreak_after_or.yaml @@ -0,0 +1,12 @@ +description: Linebreak after || continues the and-or list. +input: + script: |+ + false || + false || + + echo foo +expect: + stdout: |+ + foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/output/mixed_output_partial.yaml b/tests/scenarios/shell/logic_ops/output/mixed_output_partial.yaml new file mode 100644 index 00000000..eabf37e8 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/output/mixed_output_partial.yaml @@ -0,0 +1,11 @@ +description: Only executed commands in a mixed chain produce output. +input: + script: |+ + echo a && false || echo c && echo d +expect: + stdout: |+ + a + c + d + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/var_interact/and_exit_status_variable.yaml b/tests/scenarios/shell/logic_ops/var_interact/and_exit_status_variable.yaml new file mode 100644 index 00000000..d94d2cb9 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/var_interact/and_exit_status_variable.yaml @@ -0,0 +1,9 @@ +description: The $? variable reflects exit status within && chains. +input: + script: |+ + true && echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/var_interact/chain_var_propagation.yaml b/tests/scenarios/shell/logic_ops/var_interact/chain_var_propagation.yaml new file mode 100644 index 00000000..32855598 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/var_interact/chain_var_propagation.yaml @@ -0,0 +1,9 @@ +description: Variable propagation through an and-or chain with assignments. +input: + script: |+ + A=1 && B=2 && C=3 && echo "$A $B $C" +expect: + stdout: |+ + 1 2 3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/var_interact/exit_status_through_chain.yaml b/tests/scenarios/shell/logic_ops/var_interact/exit_status_through_chain.yaml new file mode 100644 index 00000000..79d88e63 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/var_interact/exit_status_through_chain.yaml @@ -0,0 +1,13 @@ +description: The $? variable tracks exit status correctly through an and-or chain. +input: + script: |+ + true && echo $? + false || echo $? + true && false || echo $? +expect: + stdout: |+ + 0 + 1 + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/var_interact/or_assignment_persists.yaml b/tests/scenarios/shell/logic_ops/var_interact/or_assignment_persists.yaml new file mode 100644 index 00000000..a48d366d --- /dev/null +++ b/tests/scenarios/shell/logic_ops/var_interact/or_assignment_persists.yaml @@ -0,0 +1,10 @@ +description: Variable set in || left side is visible even when left succeeds and right is skipped. +input: + script: |+ + V=set || echo "fallback" + echo "$V" +expect: + stdout: |+ + set + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/var_interact/or_exit_status_variable.yaml b/tests/scenarios/shell/logic_ops/var_interact/or_exit_status_variable.yaml new file mode 100644 index 00000000..281a1065 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/var_interact/or_exit_status_variable.yaml @@ -0,0 +1,9 @@ +description: The $? variable reflects the failed exit status in || fallback. +input: + script: |+ + false || echo $? +expect: + stdout: |+ + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/logic_ops/var_interact/or_var_visible_on_fallback.yaml b/tests/scenarios/shell/logic_ops/var_interact/or_var_visible_on_fallback.yaml new file mode 100644 index 00000000..2d128a67 --- /dev/null +++ b/tests/scenarios/shell/logic_ops/var_interact/or_var_visible_on_fallback.yaml @@ -0,0 +1,9 @@ +description: Variable set before || is visible in the fallback. +input: + script: |+ + X=world; false || echo $X +expect: + stdout: |+ + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/basic/exit_status_captured.yaml b/tests/scenarios/shell/negation/basic/exit_status_captured.yaml new file mode 100644 index 00000000..c4c36954 --- /dev/null +++ b/tests/scenarios/shell/negation/basic/exit_status_captured.yaml @@ -0,0 +1,15 @@ +description: Negated exit status is captured correctly in a variable via $?. +input: + script: |+ + ! true + A=$? + echo $A + ! false + B=$? + echo $B +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/basic/multiple_negations_sequence.yaml b/tests/scenarios/shell/negation/basic/multiple_negations_sequence.yaml new file mode 100644 index 00000000..9c864190 --- /dev/null +++ b/tests/scenarios/shell/negation/basic/multiple_negations_sequence.yaml @@ -0,0 +1,16 @@ +description: Multiple negated commands separated by semicolons track $? independently. +input: + script: |+ + ! false + echo $? + ! true + echo $? + ! false + echo $? +expect: + stdout: |+ + 0 + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/basic/negate_echo.yaml b/tests/scenarios/shell/negation/basic/negate_echo.yaml new file mode 100644 index 00000000..8ff69ca1 --- /dev/null +++ b/tests/scenarios/shell/negation/basic/negate_echo.yaml @@ -0,0 +1,9 @@ +description: Negating a successful echo still prints output but returns exit code 1. +input: + script: |+ + ! echo hello +expect: + stdout: |+ + hello + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/basic/negate_false.yaml b/tests/scenarios/shell/negation/basic/negate_false.yaml new file mode 100644 index 00000000..d303740e --- /dev/null +++ b/tests/scenarios/shell/negation/basic/negate_false.yaml @@ -0,0 +1,8 @@ +description: Negating false produces exit code 0. +input: + script: |+ + ! false +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/basic/negate_true.yaml b/tests/scenarios/shell/negation/basic/negate_true.yaml new file mode 100644 index 00000000..e24db9b3 --- /dev/null +++ b/tests/scenarios/shell/negation/basic/negate_true.yaml @@ -0,0 +1,8 @@ +description: Negating true produces exit code 1. +input: + script: |+ + ! true +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/basic/negate_unknown_cmd.yaml b/tests/scenarios/shell/negation/basic/negate_unknown_cmd.yaml new file mode 100644 index 00000000..dae3b22a --- /dev/null +++ b/tests/scenarios/shell/negation/basic/negate_unknown_cmd.yaml @@ -0,0 +1,9 @@ +description: Negating a command-not-found (exit 127) produces exit code 0. +input: + script: |+ + ! nonexistent_command_xyz +expect: + stdout: "" + stderr_contains: + - "command not found" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/compound/negate_brace_group.yaml b/tests/scenarios/shell/negation/compound/negate_brace_group.yaml new file mode 100644 index 00000000..1c32bfb0 --- /dev/null +++ b/tests/scenarios/shell/negation/compound/negate_brace_group.yaml @@ -0,0 +1,8 @@ +description: Negation can be applied to a brace group to invert its exit code. +input: + script: |+ + ! { true; } +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/compound/negate_brace_multi_cmd.yaml b/tests/scenarios/shell/negation/compound/negate_brace_multi_cmd.yaml new file mode 100644 index 00000000..5abbd798 --- /dev/null +++ b/tests/scenarios/shell/negation/compound/negate_brace_multi_cmd.yaml @@ -0,0 +1,17 @@ +description: Negation of a brace group with multiple commands inverts the exit code of the last command. +input: + script: |+ + ! { echo a; echo b; true; } + echo $? + ! { echo c; echo d; false; } + echo $? +expect: + stdout: |+ + a + b + 1 + c + d + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/compound/negate_brace_with_false.yaml b/tests/scenarios/shell/negation/compound/negate_brace_with_false.yaml new file mode 100644 index 00000000..4647b687 --- /dev/null +++ b/tests/scenarios/shell/negation/compound/negate_brace_with_false.yaml @@ -0,0 +1,9 @@ +description: Negation of a brace group where the last command fails produces exit code 0. +input: + script: |+ + ! { echo hello; false; } +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/compound/negate_for_empty_list.yaml b/tests/scenarios/shell/negation/compound/negate_for_empty_list.yaml new file mode 100644 index 00000000..70c7e741 --- /dev/null +++ b/tests/scenarios/shell/negation/compound/negate_for_empty_list.yaml @@ -0,0 +1,8 @@ +description: Negation of a for loop with an empty word list inverts exit code 0 to 1. +input: + script: |+ + ! for i in; do echo never; done +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/compound/negate_for_false_body.yaml b/tests/scenarios/shell/negation/compound/negate_for_false_body.yaml new file mode 100644 index 00000000..d6027ac4 --- /dev/null +++ b/tests/scenarios/shell/negation/compound/negate_for_false_body.yaml @@ -0,0 +1,8 @@ +description: Negation of a for loop where the body evaluates to false produces exit code 0. +input: + script: |+ + ! for i in a b; do false; done +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/compound/negate_for_loop.yaml b/tests/scenarios/shell/negation/compound/negate_for_loop.yaml new file mode 100644 index 00000000..9db47732 --- /dev/null +++ b/tests/scenarios/shell/negation/compound/negate_for_loop.yaml @@ -0,0 +1,8 @@ +description: Negation can be applied to a for loop to invert its exit code. +input: + script: |+ + ! for i in a; do true; done +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/compound/negate_nested_brace.yaml b/tests/scenarios/shell/negation/compound/negate_nested_brace.yaml new file mode 100644 index 00000000..2f2f5891 --- /dev/null +++ b/tests/scenarios/shell/negation/compound/negate_nested_brace.yaml @@ -0,0 +1,8 @@ +description: Negation of nested brace groups inverts the exit code of the innermost last command. +input: + script: |+ + ! { { true; }; } +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/exit_code/negate_exit_one.yaml b/tests/scenarios/shell/negation/exit_code/negate_exit_one.yaml new file mode 100644 index 00000000..af951e65 --- /dev/null +++ b/tests/scenarios/shell/negation/exit_code/negate_exit_one.yaml @@ -0,0 +1,8 @@ +description: Exit inside negation still exits with the specified code (negation does not apply). +input: + script: |+ + ! exit 1 +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/exit_code/negate_exit_zero.yaml b/tests/scenarios/shell/negation/exit_code/negate_exit_zero.yaml new file mode 100644 index 00000000..e836ecb1 --- /dev/null +++ b/tests/scenarios/shell/negation/exit_code/negate_exit_zero.yaml @@ -0,0 +1,8 @@ +description: Exit inside negation still exits with the specified code (negation does not apply). +input: + script: |+ + ! exit 0 +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/with_logic_ops/multiple_negations_in_and_chain.yaml b/tests/scenarios/shell/negation/with_logic_ops/multiple_negations_in_and_chain.yaml new file mode 100644 index 00000000..6e038581 --- /dev/null +++ b/tests/scenarios/shell/negation/with_logic_ops/multiple_negations_in_and_chain.yaml @@ -0,0 +1,9 @@ +description: Multiple negated pipelines chained with && all succeed when negation inverts failure to success. +input: + script: |+ + ! false && ! false && echo "all ok" +expect: + stdout: |+ + all ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/with_logic_ops/negate_and_chain.yaml b/tests/scenarios/shell/negation/with_logic_ops/negate_and_chain.yaml new file mode 100644 index 00000000..4ec5ac7c --- /dev/null +++ b/tests/scenarios/shell/negation/with_logic_ops/negate_and_chain.yaml @@ -0,0 +1,9 @@ +description: Negation applies to the entire && chain, not just the first command. +input: + script: |+ + ! echo first && echo second +expect: + stdout: |+ + first + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/with_logic_ops/negate_false_then_and.yaml b/tests/scenarios/shell/negation/with_logic_ops/negate_false_then_and.yaml new file mode 100644 index 00000000..7ff94800 --- /dev/null +++ b/tests/scenarios/shell/negation/with_logic_ops/negate_false_then_and.yaml @@ -0,0 +1,9 @@ +description: Negation applies to the pipeline only; the successful negation feeds into && which continues. +input: + script: |+ + ! false && echo "continued" +expect: + stdout: |+ + continued + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/with_logic_ops/negate_or_chain.yaml b/tests/scenarios/shell/negation/with_logic_ops/negate_or_chain.yaml new file mode 100644 index 00000000..52324a83 --- /dev/null +++ b/tests/scenarios/shell/negation/with_logic_ops/negate_or_chain.yaml @@ -0,0 +1,8 @@ +description: Negation applies only to the pipeline, so (! false) succeeds and || skips the fallback. +input: + script: |+ + ! false || echo fallback +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/with_logic_ops/negate_true_then_or.yaml b/tests/scenarios/shell/negation/with_logic_ops/negate_true_then_or.yaml new file mode 100644 index 00000000..3625c197 --- /dev/null +++ b/tests/scenarios/shell/negation/with_logic_ops/negate_true_then_or.yaml @@ -0,0 +1,9 @@ +description: Negation of true fails, triggering the || fallback. +input: + script: |+ + ! true || echo "fallback" +expect: + stdout: |+ + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/with_pipe/negate_pipe_failure.yaml b/tests/scenarios/shell/negation/with_pipe/negate_pipe_failure.yaml new file mode 100644 index 00000000..256b254d --- /dev/null +++ b/tests/scenarios/shell/negation/with_pipe/negate_pipe_failure.yaml @@ -0,0 +1,8 @@ +description: Negating a pipeline where the last command fails produces exit code 0. +input: + script: |+ + ! echo hello | false +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/negation/with_pipe/negate_pipe_success.yaml b/tests/scenarios/shell/negation/with_pipe/negate_pipe_success.yaml new file mode 100644 index 00000000..b71ced9a --- /dev/null +++ b/tests/scenarios/shell/negation/with_pipe/negate_pipe_success.yaml @@ -0,0 +1,9 @@ +description: Negating a successful pipeline produces exit code 1. +input: + script: |+ + ! echo hello | cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/negation/with_pipe/negate_three_stage_pipe.yaml b/tests/scenarios/shell/negation/with_pipe/negate_three_stage_pipe.yaml new file mode 100644 index 00000000..4bc98f89 --- /dev/null +++ b/tests/scenarios/shell/negation/with_pipe/negate_three_stage_pipe.yaml @@ -0,0 +1,9 @@ +description: Negation of a three-stage pipeline inverts the exit code of the last command. +input: + script: |+ + ! echo hello | cat | cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/pipe/basic/blank_lines_after_pipe_op.yaml b/tests/scenarios/shell/pipe/basic/blank_lines_after_pipe_op.yaml new file mode 100644 index 00000000..e8b4ff69 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/blank_lines_after_pipe_op.yaml @@ -0,0 +1,11 @@ +description: Blank lines after pipe operator are ignored and the pipeline continues. +input: + script: |+ + echo hello | + + cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/brace_both_sides.yaml b/tests/scenarios/shell/pipe/basic/brace_both_sides.yaml new file mode 100644 index 00000000..ffff27d9 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/brace_both_sides.yaml @@ -0,0 +1,10 @@ +description: Brace groups can be used on both sides of a pipeline. +input: + script: |+ + { echo first; echo second; } | { cat; } +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/brace_group_in_pipe.yaml b/tests/scenarios/shell/pipe/basic/brace_group_in_pipe.yaml new file mode 100644 index 00000000..a60d1402 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/brace_group_in_pipe.yaml @@ -0,0 +1,10 @@ +description: Brace group can be used as source in a pipeline. +input: + script: |+ + { echo foo; echo bar; } | cat +expect: + stdout: |+ + foo + bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/cat_file.yaml b/tests/scenarios/shell/pipe/basic/cat_file.yaml new file mode 100644 index 00000000..fe3da024 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/cat_file.yaml @@ -0,0 +1,17 @@ +description: File content piped from cat to cat transfers correctly. +setup: + files: + - path: data.txt + content: |+ + line one + line two +input: + allowed_paths: ["$DIR"] + script: |+ + cat data.txt | cat +expect: + stdout: |+ + line one + line two + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/chain.yaml b/tests/scenarios/shell/pipe/basic/chain.yaml new file mode 100644 index 00000000..e9acd281 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/chain.yaml @@ -0,0 +1,9 @@ +description: Triple pipe chain passes data through multiple stages. +input: + script: |+ + echo abc | cat | cat +expect: + stdout: |+ + abc + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/echo_ignores_stdin.yaml b/tests/scenarios/shell/pipe/basic/echo_ignores_stdin.yaml new file mode 100644 index 00000000..076f66a6 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/echo_ignores_stdin.yaml @@ -0,0 +1,9 @@ +description: Echo does not read stdin; piped data is discarded. +input: + script: |+ + echo hello | echo world +expect: + stdout: |+ + world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/empty_echo.yaml b/tests/scenarios/shell/pipe/basic/empty_echo.yaml new file mode 100644 index 00000000..61fb3e50 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/empty_echo.yaml @@ -0,0 +1,9 @@ +description: Piping empty echo output to cat produces a newline. +input: + script: |+ + echo "" | cat +expect: + stdout: |+ + + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/five_stage.yaml b/tests/scenarios/shell/pipe/basic/five_stage.yaml new file mode 100644 index 00000000..0af5fb33 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/five_stage.yaml @@ -0,0 +1,9 @@ +description: Five-stage pipeline passes data through all stages. +input: + script: |+ + echo hello | cat | cat | cat | cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/for_loop.yaml b/tests/scenarios/shell/pipe/basic/for_loop.yaml new file mode 100644 index 00000000..334bb177 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/for_loop.yaml @@ -0,0 +1,11 @@ +description: For loop output can be piped to another command. +input: + script: |+ + for i in a b c; do echo $i; done | cat +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/for_loop_both_sides.yaml b/tests/scenarios/shell/pipe/basic/for_loop_both_sides.yaml new file mode 100644 index 00000000..56d561e8 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/for_loop_both_sides.yaml @@ -0,0 +1,11 @@ +description: For loops can appear on both sides of a pipeline. +input: + script: |+ + for i in a b c; do echo $i; done | for w in x; do cat; done +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/for_loop_right.yaml b/tests/scenarios/shell/pipe/basic/for_loop_right.yaml new file mode 100644 index 00000000..a5ad7aaf --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/for_loop_right.yaml @@ -0,0 +1,9 @@ +description: For loop can receive input on the right side of a pipeline. +input: + script: |+ + echo "hello" | for w in x; do cat; done +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/inside_brace.yaml b/tests/scenarios/shell/pipe/basic/inside_brace.yaml new file mode 100644 index 00000000..bbd04772 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/inside_brace.yaml @@ -0,0 +1,10 @@ +description: Pipeline can be used inside a brace group alongside other commands. +input: + script: |+ + { echo a | cat; echo b; } +expect: + stdout: |+ + a + b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/linebreak.yaml b/tests/scenarios/shell/pipe/basic/linebreak.yaml new file mode 100644 index 00000000..a101e166 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/linebreak.yaml @@ -0,0 +1,11 @@ +description: A linebreak after the pipe operator continues the pipeline on the next line. +input: + script: |+ + echo hello | + cat | + cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/multiline.yaml b/tests/scenarios/shell/pipe/basic/multiline.yaml new file mode 100644 index 00000000..b627f4e9 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/multiline.yaml @@ -0,0 +1,11 @@ +description: Block producing multiple lines pipes all output through. +input: + script: |+ + { echo a; echo b; echo c; } | cat +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/no_output_left.yaml b/tests/scenarios/shell/pipe/basic/no_output_left.yaml new file mode 100644 index 00000000..b9fa13cb --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/no_output_left.yaml @@ -0,0 +1,8 @@ +description: Pipe with no output from left side produces empty stdout. +input: + script: |+ + true | cat +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/preserves_empty_lines.yaml b/tests/scenarios/shell/pipe/basic/preserves_empty_lines.yaml new file mode 100644 index 00000000..a35cc068 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/preserves_empty_lines.yaml @@ -0,0 +1,11 @@ +description: Pipeline preserves blank lines in multi-line output. +input: + script: |+ + { echo a; echo ""; echo b; } | cat +expect: + stdout: |+ + a + + b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/semicolon_between.yaml b/tests/scenarios/shell/pipe/basic/semicolon_between.yaml new file mode 100644 index 00000000..273764f5 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/semicolon_between.yaml @@ -0,0 +1,10 @@ +description: Multiple pipe statements separated by semicolons on the same line. +input: + script: |+ + echo first | cat; echo second | cat +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/sequential.yaml b/tests/scenarios/shell/pipe/basic/sequential.yaml new file mode 100644 index 00000000..a7c0bd2f --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/sequential.yaml @@ -0,0 +1,11 @@ +description: Multiple pipe statements execute independently in sequence. +input: + script: |+ + echo first | cat + echo second | cat +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/simple.yaml b/tests/scenarios/shell/pipe/basic/simple.yaml new file mode 100644 index 00000000..f3c87f2a --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/simple.yaml @@ -0,0 +1,9 @@ +description: Pipe connects stdout of one command to stdin of another. +input: + script: |+ + echo abc | cat +expect: + stdout: |+ + abc + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/var_isolation.yaml b/tests/scenarios/shell/pipe/basic/var_isolation.yaml new file mode 100644 index 00000000..5f258768 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/var_isolation.yaml @@ -0,0 +1,12 @@ +description: Variable assignment in pipe subshell does not propagate to parent shell. +input: + script: |+ + X=before + { X=after; echo $X; } | cat + echo $X +expect: + stdout: |+ + after + before + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/basic/variable_expansion.yaml b/tests/scenarios/shell/pipe/basic/variable_expansion.yaml new file mode 100644 index 00000000..72959936 --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/variable_expansion.yaml @@ -0,0 +1,10 @@ +description: Variable expansion works on the left side of a pipe. +input: + script: |+ + X=hello + echo $X world | cat +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/errors/left.yaml b/tests/scenarios/shell/pipe/errors/left.yaml new file mode 100644 index 00000000..6671cd5a --- /dev/null +++ b/tests/scenarios/shell/pipe/errors/left.yaml @@ -0,0 +1,9 @@ +description: Unknown command on left side of pipe sends error to stderr; right side still runs. +input: + script: |+ + foo | echo ok +expect: + stdout: |+ + ok + stderr_contains: ["foo", "command not found"] + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/errors/right.yaml b/tests/scenarios/shell/pipe/errors/right.yaml new file mode 100644 index 00000000..64faa8da --- /dev/null +++ b/tests/scenarios/shell/pipe/errors/right.yaml @@ -0,0 +1,8 @@ +description: Unknown command on right side of pipe sets exit code 127. +input: + script: |+ + echo ok | foo +expect: + stdout: "" + stderr_contains: ["foo", "command not found"] + exit_code: 127 diff --git a/tests/scenarios/shell/pipe/exit_code/dollar_question_in_pipe.yaml b/tests/scenarios/shell/pipe/exit_code/dollar_question_in_pipe.yaml new file mode 100644 index 00000000..fcba5d6d --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/dollar_question_in_pipe.yaml @@ -0,0 +1,10 @@ +description: The $? variable from a previous command is available inside a pipeline. +input: + script: |+ + false + echo $? | cat +expect: + stdout: |+ + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/exit_code_from_right.yaml b/tests/scenarios/shell/pipe/exit_code/exit_code_from_right.yaml new file mode 100644 index 00000000..a536180e --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/exit_code_from_right.yaml @@ -0,0 +1,8 @@ +description: Pipe exit code comes from the rightmost (last) command. +input: + script: |+ + echo ok | false +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/pipe/exit_code/exit_left.yaml b/tests/scenarios/shell/pipe/exit_code/exit_left.yaml new file mode 100644 index 00000000..0e15ddd3 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/exit_left.yaml @@ -0,0 +1,9 @@ +description: Exit on left side of pipe does not propagate to the parent shell. +input: + script: |+ + exit 42 | echo ok +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/exit_not_from_middle.yaml b/tests/scenarios/shell/pipe/exit_code/exit_not_from_middle.yaml new file mode 100644 index 00000000..5d09cec8 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/exit_not_from_middle.yaml @@ -0,0 +1,10 @@ +description: Middle command failure in a 3-stage pipeline does not affect exit status when last succeeds. +input: + script: |+ + exit 42 | exit 99 | exit 0 + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/exit_right.yaml b/tests/scenarios/shell/pipe/exit_code/exit_right.yaml new file mode 100644 index 00000000..3e104a4f --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/exit_right.yaml @@ -0,0 +1,8 @@ +description: Exit on right side of pipe sets the pipe exit code. +input: + script: |+ + echo ok | exit 42 +expect: + stdout: "" + stderr: "" + exit_code: 42 diff --git a/tests/scenarios/shell/pipe/exit_code/false_true.yaml b/tests/scenarios/shell/pipe/exit_code/false_true.yaml new file mode 100644 index 00000000..989ddbe7 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/false_true.yaml @@ -0,0 +1,8 @@ +description: Right side true overrides left side false exit code. +input: + script: |+ + false | true +expect: + stdout: "" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/four_stage.yaml b/tests/scenarios/shell/pipe/exit_code/four_stage.yaml new file mode 100644 index 00000000..b57f1a21 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/four_stage.yaml @@ -0,0 +1,13 @@ +description: In a 4-stage pipeline, the last command's exit code determines the pipeline status. +input: + script: |+ + exit 1 | exit 2 | exit 3 | exit 4 + echo $? + exit 9 | exit 8 | exit 7 | exit 0 + echo $? +expect: + stdout: |+ + 4 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/last_command_determines.yaml b/tests/scenarios/shell/pipe/exit_code/last_command_determines.yaml new file mode 100644 index 00000000..ad894775 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/last_command_determines.yaml @@ -0,0 +1,16 @@ +description: Pipeline exit status is determined by the last command. +input: + script: |+ + true | true | true + echo a $? + false | false | true + echo b $? + true | true | false + echo c $? +expect: + stdout: |+ + a 0 + b 0 + c 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/left_fails_right_succeeds.yaml b/tests/scenarios/shell/pipe/exit_code/left_fails_right_succeeds.yaml new file mode 100644 index 00000000..a9a7fb15 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/left_fails_right_succeeds.yaml @@ -0,0 +1,9 @@ +description: Left side failure does not affect exit code when right side succeeds. +input: + script: |+ + false | echo ok +expect: + stdout: |+ + ok + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/negated_pipeline.yaml b/tests/scenarios/shell/pipe/exit_code/negated_pipeline.yaml new file mode 100644 index 00000000..9d72fc76 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/negated_pipeline.yaml @@ -0,0 +1,13 @@ +description: Negation inverts the exit status of a pipeline. +input: + script: |+ + ! exit 0 | exit 0 + echo $? + ! exit 0 | exit 4 + echo $? +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/negated_three_stage_nonzero.yaml b/tests/scenarios/shell/pipe/exit_code/negated_three_stage_nonzero.yaml new file mode 100644 index 00000000..f0c5390f --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/negated_three_stage_nonzero.yaml @@ -0,0 +1,10 @@ +description: Negation of a 3-stage pipeline where the last command exits non-zero inverts to 0. +input: + script: |+ + ! exit 0 | exit 0 | exit 4 + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/negated_three_stage_zero.yaml b/tests/scenarios/shell/pipe/exit_code/negated_three_stage_zero.yaml new file mode 100644 index 00000000..d1a5a137 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/negated_three_stage_zero.yaml @@ -0,0 +1,10 @@ +description: Negation of a 3-stage pipeline where the last command exits 0 inverts to 1. +input: + script: |+ + ! exit 1 | exit 2 | exit 0 + echo $? +expect: + stdout: |+ + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/pipeline_status_via_dollar_question.yaml b/tests/scenarios/shell/pipe/exit_code/pipeline_status_via_dollar_question.yaml new file mode 100644 index 00000000..9fd583d4 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/pipeline_status_via_dollar_question.yaml @@ -0,0 +1,13 @@ +description: Pipeline exit status is from the last command and can be checked via $?. +input: + script: |+ + echo foo | true + echo $? + echo bar | false + echo $? +expect: + stdout: |+ + 0 + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/three_stage_all_nonzero.yaml b/tests/scenarios/shell/pipe/exit_code/three_stage_all_nonzero.yaml new file mode 100644 index 00000000..560f0037 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/three_stage_all_nonzero.yaml @@ -0,0 +1,10 @@ +description: In a 3-stage pipeline where all commands fail, the last command's exit code is used. +input: + script: |+ + exit 5 | exit 6 | exit 7 + echo $? +expect: + stdout: |+ + 7 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/three_stage_last_nonzero.yaml b/tests/scenarios/shell/pipe/exit_code/three_stage_last_nonzero.yaml new file mode 100644 index 00000000..029aa341 --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/three_stage_last_nonzero.yaml @@ -0,0 +1,10 @@ +description: In a 3-stage pipeline, a non-zero exit from the last command determines the exit status. +input: + script: |+ + exit 0 | exit 0 | exit 4 + echo $? +expect: + stdout: |+ + 4 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/exit_code/true_false.yaml b/tests/scenarios/shell/pipe/exit_code/true_false.yaml new file mode 100644 index 00000000..b4ab436e --- /dev/null +++ b/tests/scenarios/shell/pipe/exit_code/true_false.yaml @@ -0,0 +1,8 @@ +description: Pipe of two builtins returns exit code from right side. +input: + script: |+ + true | false +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/pipe/logic_ops/and_failure.yaml b/tests/scenarios/shell/pipe/logic_ops/and_failure.yaml new file mode 100644 index 00000000..87b94727 --- /dev/null +++ b/tests/scenarios/shell/pipe/logic_ops/and_failure.yaml @@ -0,0 +1,8 @@ +description: Failed pipe followed by && skips the next command. +input: + script: |+ + echo abc | false && echo done +expect: + stdout: "" + stderr: "" + exit_code: 1 diff --git a/tests/scenarios/shell/pipe/logic_ops/and_success.yaml b/tests/scenarios/shell/pipe/logic_ops/and_success.yaml new file mode 100644 index 00000000..d9b20c7a --- /dev/null +++ b/tests/scenarios/shell/pipe/logic_ops/and_success.yaml @@ -0,0 +1,10 @@ +description: Successful pipe followed by && executes the next command. +input: + script: |+ + echo abc | cat && echo done +expect: + stdout: |+ + abc + done + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/logic_ops/or_failure.yaml b/tests/scenarios/shell/pipe/logic_ops/or_failure.yaml new file mode 100644 index 00000000..790e42ab --- /dev/null +++ b/tests/scenarios/shell/pipe/logic_ops/or_failure.yaml @@ -0,0 +1,9 @@ +description: Failed pipe followed by || executes the fallback command. +input: + script: |+ + echo abc | false || echo fallback +expect: + stdout: |+ + fallback + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/pipe/logic_ops/or_success.yaml b/tests/scenarios/shell/pipe/logic_ops/or_success.yaml new file mode 100644 index 00000000..587d7c03 --- /dev/null +++ b/tests/scenarios/shell/pipe/logic_ops/or_success.yaml @@ -0,0 +1,9 @@ +description: Successful pipe followed by || skips the fallback command. +input: + script: |+ + echo abc | cat || echo fallback +expect: + stdout: |+ + abc + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/readonly/blocked.yaml b/tests/scenarios/shell/readonly/blocked.yaml new file mode 100644 index 00000000..6ac64585 --- /dev/null +++ b/tests/scenarios/shell/readonly/blocked.yaml @@ -0,0 +1,9 @@ +test_against_local_shell: false +description: The readonly keyword is not supported and is blocked at parse validation. +input: + script: |+ + readonly X=5 +expect: + stdout: "" + stderr: "readonly is not supported\n" + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/basic/adjacent_expansions.yaml b/tests/scenarios/shell/var_expand/basic/adjacent_expansions.yaml new file mode 100644 index 00000000..1d7cd9e8 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/adjacent_expansions.yaml @@ -0,0 +1,14 @@ +description: Adjacent variable expansions without spaces are concatenated. +input: + script: |+ + A=one + B=two + C=three + echo "$A$B$C" + echo $A$B$C +expect: + stdout: |+ + onetwothree + onetwothree + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/adjacent_text.yaml b/tests/scenarios/shell/var_expand/basic/adjacent_text.yaml new file mode 100644 index 00000000..8556c735 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/adjacent_text.yaml @@ -0,0 +1,10 @@ +description: Braces disambiguate variable names adjacent to other text. +input: + script: |+ + FRUIT=apple + echo "${FRUIT}sauce" +expect: + stdout: |+ + applesauce + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/assign_from_unset.yaml b/tests/scenarios/shell/var_expand/basic/assign_from_unset.yaml new file mode 100644 index 00000000..16dc8fc8 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/assign_from_unset.yaml @@ -0,0 +1,10 @@ +description: Assigning from an unset variable gives an empty string. +input: + script: |+ + A=$NEVER_SET_VAR + echo "[$A]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/assign_self_reference.yaml b/tests/scenarios/shell/var_expand/basic/assign_self_reference.yaml new file mode 100644 index 00000000..7990af95 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/assign_self_reference.yaml @@ -0,0 +1,11 @@ +description: A variable can reference its own previous value during reassignment. +input: + script: |+ + A=hello + A="${A}_world" + echo "$A" +expect: + stdout: |+ + hello_world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/assignment_equals_in_value.yaml b/tests/scenarios/shell/var_expand/basic/assignment_equals_in_value.yaml new file mode 100644 index 00000000..998fed44 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/assignment_equals_in_value.yaml @@ -0,0 +1,10 @@ +description: Assignment value can contain equals signs. +input: + script: |+ + KEY="a=b=c" + echo "$KEY" +expect: + stdout: |+ + a=b=c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/assignment_order_same_line.yaml b/tests/scenarios/shell/var_expand/basic/assignment_order_same_line.yaml new file mode 100644 index 00000000..611edccf --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/assignment_order_same_line.yaml @@ -0,0 +1,10 @@ +description: Multiple assignments on the same line are processed left-to-right. +input: + script: |+ + a=foo b=$a + echo "$b" +expect: + stdout: |+ + foo + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/bare_assignment.yaml b/tests/scenarios/shell/var_expand/basic/bare_assignment.yaml new file mode 100644 index 00000000..bb9e0fdf --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/bare_assignment.yaml @@ -0,0 +1,10 @@ +description: Bare assignment VAR= without quotes assigns an empty string. +input: + script: |+ + VAR= + echo "[$VAR]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/braces.yaml b/tests/scenarios/shell/var_expand/basic/braces.yaml new file mode 100644 index 00000000..bacafa47 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/braces.yaml @@ -0,0 +1,10 @@ +description: Variables can be expanded using brace syntax ${VAR}. +input: + script: |+ + GREETING=hello + echo ${GREETING} +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/braces_disambiguate_suffix.yaml b/tests/scenarios/shell/var_expand/basic/braces_disambiguate_suffix.yaml new file mode 100644 index 00000000..8bcc6dad --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/braces_disambiguate_suffix.yaml @@ -0,0 +1,12 @@ +description: Braces disambiguate variable name from adjacent characters. +input: + script: |+ + a=hello + echo "${a}b" + echo "$ab" +expect: + stdout: |+ + hellob + + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/case_sensitive.yaml b/tests/scenarios/shell/var_expand/basic/case_sensitive.yaml new file mode 100644 index 00000000..69527102 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/case_sensitive.yaml @@ -0,0 +1,11 @@ +description: Variable names are case-sensitive. +input: + script: |+ + foo=lower + FOO=upper + echo $foo $FOO +expect: + stdout: |+ + lower upper + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/chain_assignment.yaml b/tests/scenarios/shell/var_expand/basic/chain_assignment.yaml new file mode 100644 index 00000000..a860f062 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/chain_assignment.yaml @@ -0,0 +1,11 @@ +description: A variable can be assigned from another variable's value. +input: + script: |+ + A=hello + B=$A + echo $B +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/concatenation.yaml b/tests/scenarios/shell/var_expand/basic/concatenation.yaml new file mode 100644 index 00000000..36c59e3b --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/concatenation.yaml @@ -0,0 +1,13 @@ +description: Variable concatenation with adjacent expansions in double quotes. +input: + script: |+ + A=hello + B=world + echo "$A$B" + echo "${A}_${B}" +expect: + stdout: |+ + helloworld + hello_world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/empty_value.yaml b/tests/scenarios/shell/var_expand/basic/empty_value.yaml new file mode 100644 index 00000000..f8302523 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/empty_value.yaml @@ -0,0 +1,10 @@ +description: A variable assigned an empty string is set but empty. +input: + script: |+ + EMPTY="" + echo "value=${EMPTY}end" +expect: + stdout: |+ + value=end + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/exit_status_of_assignment.yaml b/tests/scenarios/shell/var_expand/basic/exit_status_of_assignment.yaml new file mode 100644 index 00000000..94910b72 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/exit_status_of_assignment.yaml @@ -0,0 +1,10 @@ +description: Variable assignment alone has exit status 0. +input: + script: |+ + a=1 + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/expansion_mid_word.yaml b/tests/scenarios/shell/var_expand/basic/expansion_mid_word.yaml new file mode 100644 index 00000000..c70766bd --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/expansion_mid_word.yaml @@ -0,0 +1,10 @@ +description: Variable expansion embedded in the middle of a word. +input: + script: |+ + NAME=world + echo "prefix_${NAME}_suffix" +expect: + stdout: |+ + prefix_world_suffix + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/long_varname.yaml b/tests/scenarios/shell/var_expand/basic/long_varname.yaml new file mode 100644 index 00000000..9cee10a8 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/long_varname.yaml @@ -0,0 +1,10 @@ +description: Long variable names with underscores and digits work correctly. +input: + script: |+ + _L0NG_variable_NAME_123=value + echo "$_L0NG_variable_NAME_123" +expect: + stdout: |+ + value + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/multiple.yaml b/tests/scenarios/shell/var_expand/basic/multiple.yaml new file mode 100644 index 00000000..8ced823b --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/multiple.yaml @@ -0,0 +1,11 @@ +description: Multiple variables can be expanded in the same command. +input: + script: |+ + A=hello + B=world + echo $A $B +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/multiple_assign_with_expansion.yaml b/tests/scenarios/shell/var_expand/basic/multiple_assign_with_expansion.yaml new file mode 100644 index 00000000..a2e82c5a --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/multiple_assign_with_expansion.yaml @@ -0,0 +1,11 @@ +description: Multiple assignments across lines with variable expansion (inspired by yash simple-p.tst). +input: + script: |+ + a= b=bar + c=$b d=X e=$a + echo "[$c] [$d] [$e]" +expect: + stdout: |+ + [bar] [X] [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/multiple_same_line.yaml b/tests/scenarios/shell/var_expand/basic/multiple_same_line.yaml new file mode 100644 index 00000000..5bc48f56 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/multiple_same_line.yaml @@ -0,0 +1,10 @@ +description: Multiple variables can be assigned on a single line. +input: + script: |+ + a=foo b=bar + echo $a $b +expect: + stdout: |+ + foo bar + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/newline_in_value.yaml b/tests/scenarios/shell/var_expand/basic/newline_in_value.yaml new file mode 100644 index 00000000..f2971766 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/newline_in_value.yaml @@ -0,0 +1,12 @@ +description: Variable value containing a newline preserves it when quoted. +input: + script: |+ + VAR="line1 + line2" + echo "$VAR" +expect: + stdout: |+ + line1 + line2 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/overwrite_value.yaml b/tests/scenarios/shell/var_expand/basic/overwrite_value.yaml new file mode 100644 index 00000000..b1ef5787 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/overwrite_value.yaml @@ -0,0 +1,16 @@ +description: A variable can be overwritten from empty to non-empty and vice versa. +input: + script: |+ + VAR= + echo "[$VAR]" + VAR=hello + echo "[$VAR]" + VAR= + echo "[$VAR]" +expect: + stdout: |+ + [] + [hello] + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/reassignment.yaml b/tests/scenarios/shell/var_expand/basic/reassignment.yaml new file mode 100644 index 00000000..dbd03179 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/reassignment.yaml @@ -0,0 +1,13 @@ +description: Reassigning a variable updates its value for subsequent expansions. +input: + script: |+ + X=first + echo $X + X=second + echo $X +expect: + stdout: |+ + first + second + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/simple_assignment.yaml b/tests/scenarios/shell/var_expand/basic/simple_assignment.yaml new file mode 100644 index 00000000..2b4caf11 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/simple_assignment.yaml @@ -0,0 +1,10 @@ +description: A variable can be assigned and expanded with the dollar sign. +input: + script: |+ + MY_VAR=hello + echo $MY_VAR +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/special_chars_in_value.yaml b/tests/scenarios/shell/var_expand/basic/special_chars_in_value.yaml new file mode 100644 index 00000000..df635cb2 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/special_chars_in_value.yaml @@ -0,0 +1,13 @@ +description: Assignment values can contain special characters when quoted. +input: + script: |+ + A="hello!world" + B='@#$%^' + echo "$A" + echo "$B" +expect: + stdout: |+ + hello!world + @#$%^ + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/tab_in_value.yaml b/tests/scenarios/shell/var_expand/basic/tab_in_value.yaml new file mode 100644 index 00000000..33e9aeb9 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/tab_in_value.yaml @@ -0,0 +1,7 @@ +description: Variable value containing a tab preserves it when quoted. +input: + script: "VAR=\"a\tb\"\necho \"$VAR\"\n" +expect: + stdout: "a\tb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/underscore_in_name.yaml b/tests/scenarios/shell/var_expand/basic/underscore_in_name.yaml new file mode 100644 index 00000000..c8d95aa2 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/underscore_in_name.yaml @@ -0,0 +1,12 @@ +description: Variable names with underscores and digits work correctly. +input: + script: |+ + my_var_1=alpha + _leading=beta + A_B_C=gamma + echo $my_var_1 $_leading $A_B_C +expect: + stdout: |+ + alpha beta gamma + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/unset_expands_empty.yaml b/tests/scenarios/shell/var_expand/basic/unset_expands_empty.yaml new file mode 100644 index 00000000..1c631c94 --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/unset_expands_empty.yaml @@ -0,0 +1,9 @@ +description: An unset variable expands to an empty string by default. +input: + script: |+ + echo "before${UNSET_VAR}after" +expect: + stdout: |+ + beforeafter + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/basic/with_spaces_in_value.yaml b/tests/scenarios/shell/var_expand/basic/with_spaces_in_value.yaml new file mode 100644 index 00000000..0613b54c --- /dev/null +++ b/tests/scenarios/shell/var_expand/basic/with_spaces_in_value.yaml @@ -0,0 +1,10 @@ +description: Variables with spaces must be quoted to preserve the value as one argument. +input: + script: |+ + MSG="hello world" + echo "$MSG" +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_features/all_params.yaml b/tests/scenarios/shell/var_expand/blocked_features/all_params.yaml new file mode 100644 index 00000000..6700b5d4 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/all_params.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: All positional parameters $@ is not supported. +input: + script: |+ + echo $@ +expect: + stdout: "" + stderr: |+ + $@ is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/all_params_star.yaml b/tests/scenarios/shell/var_expand/blocked_features/all_params_star.yaml new file mode 100644 index 00000000..f059fa5d --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/all_params_star.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: All positional parameters $* is not supported. +input: + script: |+ + echo $* +expect: + stdout: "" + stderr: |+ + $* is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/alternative.yaml b/tests/scenarios/shell/var_expand/blocked_features/alternative.yaml new file mode 100644 index 00000000..bb8a17e3 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/alternative.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Alternative value expansion ${var:+alt} is not supported. +input: + script: |+ + X=set + echo ${X:+alt} +expect: + stdout: "" + stderr: |+ + ${var} operations (defaults, pattern removal, case conversion) are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/append.yaml b/tests/scenarios/shell/var_expand/blocked_features/append.yaml new file mode 100644 index 00000000..4a68c77c --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/append.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: The += append operator is not supported. +input: + script: |+ + X=hello + X+=world +expect: + stdout: "" + stderr: |+ + += is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/arithmetic.yaml b/tests/scenarios/shell/var_expand/blocked_features/arithmetic.yaml new file mode 100644 index 00000000..6b6755ff --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/arithmetic.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Arithmetic expansion is not supported. +input: + script: |+ + echo $((1+2)) +expect: + stdout: "" + stderr: |+ + arithmetic expansion is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/array.yaml b/tests/scenarios/shell/var_expand/blocked_features/array.yaml new file mode 100644 index 00000000..b7439794 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/array.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Array assignment is not supported. +input: + script: |+ + ARR=(a b c) +expect: + stdout: "" + stderr: |+ + array assignment is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/array_index.yaml b/tests/scenarios/shell/var_expand/blocked_features/array_index.yaml new file mode 100644 index 00000000..c0d61fdc --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/array_index.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Array indexing ${var[i]} is not supported. +input: + script: |+ + echo ${PATH[0]} +expect: + stdout: "" + stderr: |+ + array indexing is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/array_index_assign.yaml b/tests/scenarios/shell/var_expand/blocked_features/array_index_assign.yaml new file mode 100644 index 00000000..9fdb5f25 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/array_index_assign.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Array index assignment arr[0]=value is not supported. +input: + script: |+ + arr[0]=hello +expect: + stdout: "" + stderr: |+ + array index assignment is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/assign_default.yaml b/tests/scenarios/shell/var_expand/blocked_features/assign_default.yaml new file mode 100644 index 00000000..edff640e --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/assign_default.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Assign default expansion ${var:=default} is not supported. +input: + script: |+ + echo ${UNSET:=fallback} +expect: + stdout: "" + stderr: |+ + ${var} operations (defaults, pattern removal, case conversion) are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/case_conversion.yaml b/tests/scenarios/shell/var_expand/blocked_features/case_conversion.yaml new file mode 100644 index 00000000..f1da0f8a --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/case_conversion.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Case conversion ${var^^} is not supported. +input: + script: |+ + X=hello + echo ${X^^} +expect: + stdout: "" + stderr: |+ + ${var} operations (defaults, pattern removal, case conversion) are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/command_sub.yaml b/tests/scenarios/shell/var_expand/blocked_features/command_sub.yaml new file mode 100644 index 00000000..7452993e --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/command_sub.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Command substitution is not supported. +input: + script: |+ + echo $(echo hello) +expect: + stdout: "" + stderr: |+ + command substitution is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/command_sub_backtick.yaml b/tests/scenarios/shell/var_expand/blocked_features/command_sub_backtick.yaml new file mode 100644 index 00000000..db8d999c --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/command_sub_backtick.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Backtick command substitution is not supported. +input: + script: |+ + echo `echo hello` +expect: + stdout: "" + stderr: |+ + command substitution is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/default_value.yaml b/tests/scenarios/shell/var_expand/blocked_features/default_value.yaml new file mode 100644 index 00000000..05dcb41c --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/default_value.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Default value expansion ${var:-default} is not supported. +input: + script: |+ + echo ${UNSET:-fallback} +expect: + stdout: "" + stderr: |+ + ${var} operations (defaults, pattern removal, case conversion) are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/error_unset.yaml b/tests/scenarios/shell/var_expand/blocked_features/error_unset.yaml new file mode 100644 index 00000000..6106bccf --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/error_unset.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Error if unset expansion ${var:?message} is not supported. +input: + script: |+ + echo ${UNSET:?message} +expect: + stdout: "" + stderr: |+ + ${var} operations (defaults, pattern removal, case conversion) are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/indirect.yaml b/tests/scenarios/shell/var_expand/blocked_features/indirect.yaml new file mode 100644 index 00000000..34c9f930 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/indirect.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Indirect expansion ${!var} is not supported. +input: + script: |+ + X=HOME + echo ${!X} +expect: + stdout: "" + stderr: |+ + ${!var} is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/param_count.yaml b/tests/scenarios/shell/var_expand/blocked_features/param_count.yaml new file mode 100644 index 00000000..9d5cb662 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/param_count.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Parameter count $# is not supported. +input: + script: |+ + echo $# +expect: + stdout: "" + stderr: |+ + $# is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/positional_params.yaml b/tests/scenarios/shell/var_expand/blocked_features/positional_params.yaml new file mode 100644 index 00000000..026936fe --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/positional_params.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Positional parameter $1 is not supported. +input: + script: |+ + echo $1 +expect: + stdout: "" + stderr: |+ + $1 is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/prefix_list.yaml b/tests/scenarios/shell/var_expand/blocked_features/prefix_list.yaml new file mode 100644 index 00000000..41c8a349 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/prefix_list.yaml @@ -0,0 +1,12 @@ +test_against_local_shell: false +description: Prefix variable listing ${!prefix*} is not supported (caught by indirect expansion check). +input: + script: |+ + MY_A=1 + MY_B=2 + echo ${!MY_*} +expect: + stdout: "" + stderr: |+ + ${!var} is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/prefix_removal.yaml b/tests/scenarios/shell/var_expand/blocked_features/prefix_removal.yaml new file mode 100644 index 00000000..96766085 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/prefix_removal.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Prefix removal ${var#pattern} is not supported. +input: + script: |+ + X=/a/b/c + echo ${X#*/} +expect: + stdout: "" + stderr: |+ + ${var} operations (defaults, pattern removal, case conversion) are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/replace.yaml b/tests/scenarios/shell/var_expand/blocked_features/replace.yaml new file mode 100644 index 00000000..11ff8ee5 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/replace.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Pattern replacement ${var/pattern/replacement} is not supported. +input: + script: |+ + X=hello + echo ${X/l/L} +expect: + stdout: "" + stderr: |+ + ${var/pattern/replacement} is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/script_name.yaml b/tests/scenarios/shell/var_expand/blocked_features/script_name.yaml new file mode 100644 index 00000000..1783502b --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/script_name.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Script name $0 is not supported. +input: + script: |+ + echo $0 +expect: + stdout: "" + stderr: |+ + $0 is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/string_length.yaml b/tests/scenarios/shell/var_expand/blocked_features/string_length.yaml new file mode 100644 index 00000000..641d91f3 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/string_length.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: String length expansion ${#var} is not supported. +input: + script: |+ + X=hello + echo ${#X} +expect: + stdout: "" + stderr: |+ + ${#var} is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/substring.yaml b/tests/scenarios/shell/var_expand/blocked_features/substring.yaml new file mode 100644 index 00000000..1bb8c58a --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/substring.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Substring expansion ${var:offset:length} is not supported. +input: + script: |+ + X=hello + echo ${X:1:3} +expect: + stdout: "" + stderr: |+ + ${var:offset} is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_features/suffix_removal.yaml b/tests/scenarios/shell/var_expand/blocked_features/suffix_removal.yaml new file mode 100644 index 00000000..b6d81c26 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_features/suffix_removal.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Suffix removal ${var%pattern} is not supported. +input: + script: |+ + X=file.txt + echo ${X%.txt} +expect: + stdout: "" + stderr: |+ + ${var} operations (defaults, pattern removal, case conversion) are not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/assignment.yaml b/tests/scenarios/shell/var_expand/blocked_variables/assignment.yaml new file mode 100644 index 00000000..a53ce84b --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/assignment.yaml @@ -0,0 +1,11 @@ +test_against_local_shell: false +description: Variables without special meaning can be assigned and used as regular variables. +input: + script: |+ + RANDOM=42 + echo $RANDOM +expect: + stdout: |+ + 42 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/background_pid.yaml b/tests/scenarios/shell/var_expand/blocked_variables/background_pid.yaml new file mode 100644 index 00000000..b350e39c --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/background_pid.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $! variable is blocked. +input: + script: |+ + echo "[$!]" +expect: + stdout: "" + stderr: |+ + $! is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/euid.yaml b/tests/scenarios/shell/var_expand/blocked_variables/euid.yaml new file mode 100644 index 00000000..48f5048a --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/euid.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $EUID variable has no special meaning and expands to empty. +input: + script: |+ + echo "[$EUID]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/gid.yaml b/tests/scenarios/shell/var_expand/blocked_variables/gid.yaml new file mode 100644 index 00000000..b1bffd46 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/gid.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $GID variable has no special meaning and expands to empty. +input: + script: |+ + echo "[$GID]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/in_braces.yaml b/tests/scenarios/shell/var_expand/blocked_variables/in_braces.yaml new file mode 100644 index 00000000..d09ab213 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/in_braces.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: Variables without special meaning also expand to empty with brace syntax. +input: + script: |+ + echo "[${RANDOM}]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/last_argument.yaml b/tests/scenarios/shell/var_expand/blocked_variables/last_argument.yaml new file mode 100644 index 00000000..c337dc28 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/last_argument.yaml @@ -0,0 +1,12 @@ +test_against_local_shell: false +description: The $_ variable (last argument) expands to empty. +input: + script: |+ + echo hello + echo "[$_]" +expect: + stdout: |+ + hello + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/lineno.yaml b/tests/scenarios/shell/var_expand/blocked_variables/lineno.yaml new file mode 100644 index 00000000..85a60e13 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/lineno.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $LINENO variable is not supported. +input: + script: |+ + echo $LINENO +expect: + stdout: "" + stderr: |+ + $LINENO is not supported + exit_code: 2 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/pid.yaml b/tests/scenarios/shell/var_expand/blocked_variables/pid.yaml new file mode 100644 index 00000000..324a7f8a --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/pid.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $$ variable has no special meaning and expands to empty. +input: + script: |+ + echo "$$" +expect: + stdout: |+ + + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/ppid.yaml b/tests/scenarios/shell/var_expand/blocked_variables/ppid.yaml new file mode 100644 index 00000000..5923c555 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/ppid.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $PPID variable has no special meaning and expands to empty. +input: + script: |+ + echo "[$PPID]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/random.yaml b/tests/scenarios/shell/var_expand/blocked_variables/random.yaml new file mode 100644 index 00000000..35ca9d88 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/random.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $RANDOM variable has no special meaning and expands to empty. +input: + script: |+ + echo "[$RANDOM]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/shell_options.yaml b/tests/scenarios/shell/var_expand/blocked_variables/shell_options.yaml new file mode 100644 index 00000000..7203773c --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/shell_options.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $- variable (shell options) expands to empty. +input: + script: |+ + echo "[$-]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/srandom.yaml b/tests/scenarios/shell/var_expand/blocked_variables/srandom.yaml new file mode 100644 index 00000000..9197dfc2 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/srandom.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $SRANDOM variable has no special meaning and expands to empty. +input: + script: |+ + echo "[$SRANDOM]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/stops_execution.yaml b/tests/scenarios/shell/var_expand/blocked_variables/stops_execution.yaml new file mode 100644 index 00000000..8a8965be --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/stops_execution.yaml @@ -0,0 +1,14 @@ +test_against_local_shell: false +description: Variables without special meaning expand to empty but execution continues. +input: + script: |+ + echo "before" + echo "$$" + echo "after" +expect: + stdout: |+ + before + + after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/blocked_variables/uid.yaml b/tests/scenarios/shell/var_expand/blocked_variables/uid.yaml new file mode 100644 index 00000000..89c6d454 --- /dev/null +++ b/tests/scenarios/shell/var_expand/blocked_variables/uid.yaml @@ -0,0 +1,10 @@ +test_against_local_shell: false +description: The $UID variable has no special meaning and expands to empty. +input: + script: |+ + echo "[$UID]" +expect: + stdout: |+ + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/backslash_dquote_in_dquotes.yaml b/tests/scenarios/shell/var_expand/quoting/backslash_dquote_in_dquotes.yaml new file mode 100644 index 00000000..c37d2d71 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/backslash_dquote_in_dquotes.yaml @@ -0,0 +1,9 @@ +description: Escaped double quote inside double quotes produces a literal double quote. +input: + script: |+ + echo "a\"b\"c" +expect: + stdout: |+ + a"b"c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/backslash_escaping.yaml b/tests/scenarios/shell/var_expand/quoting/backslash_escaping.yaml new file mode 100644 index 00000000..abfdd3aa --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/backslash_escaping.yaml @@ -0,0 +1,9 @@ +description: Backslash escapes dollar sign and ampersand outside quotes. +input: + script: |+ + echo \$ \& \\ +expect: + stdout: |+ + $ & \ + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/backslash_in_double_quotes.yaml b/tests/scenarios/shell/var_expand/quoting/backslash_in_double_quotes.yaml new file mode 100644 index 00000000..c754983e --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/backslash_in_double_quotes.yaml @@ -0,0 +1,11 @@ +description: Backslash-backslash inside double quotes produces a single literal backslash. +input: + script: |+ + echo "a\\b" + echo "a\\\\b" +expect: + stdout: |+ + a\b + a\\b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/backslash_nonspecial_in_dquotes.yaml b/tests/scenarios/shell/var_expand/quoting/backslash_nonspecial_in_dquotes.yaml new file mode 100644 index 00000000..be4c17ab --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/backslash_nonspecial_in_dquotes.yaml @@ -0,0 +1,9 @@ +description: Backslash before a non-special character inside double quotes is preserved literally. +input: + script: |+ + echo "a\nb\tc" +expect: + stdout: |+ + a\nb\tc + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/backslash_normal_chars.yaml b/tests/scenarios/shell/var_expand/quoting/backslash_normal_chars.yaml new file mode 100644 index 00000000..b6e9ada0 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/backslash_normal_chars.yaml @@ -0,0 +1,9 @@ +description: Backslash-quoting non-special characters outside quotes removes the backslash and the character is literal. +input: + script: |+ + echo \a\b\c \x\y\z \0\1\2 +expect: + stdout: |+ + abc xyz 012 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/backslash_special_chars.yaml b/tests/scenarios/shell/var_expand/quoting/backslash_special_chars.yaml new file mode 100644 index 00000000..56b66082 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/backslash_special_chars.yaml @@ -0,0 +1,9 @@ +description: Backslash-quoting special characters outside any quotes removes the backslash. +input: + script: |+ + echo \! \# \( \) \{ \} +expect: + stdout: |+ + ! # ( ) { } + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/braces_in_double_quotes.yaml b/tests/scenarios/shell/var_expand/quoting/braces_in_double_quotes.yaml new file mode 100644 index 00000000..4e516057 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/braces_in_double_quotes.yaml @@ -0,0 +1,10 @@ +description: Brace syntax works inside double quotes. +input: + script: |+ + ANIMAL=cat + echo "the ${ANIMAL}s" +expect: + stdout: |+ + the cats + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_adjacent_words.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_adjacent_words.yaml new file mode 100644 index 00000000..3f56e652 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_adjacent_words.yaml @@ -0,0 +1,11 @@ +description: Adjacent double-quoted segments form a single word. +input: + script: |+ + echo "abc""def""ghi" + echo "a""b""c""d" +expect: + stdout: |+ + abcdefghi + abcd + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_backtick.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_backtick.yaml new file mode 100644 index 00000000..3a53e531 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_backtick.yaml @@ -0,0 +1,9 @@ +description: Backslash before backtick in double quotes escapes it to a literal backtick. +input: + script: |+ + echo "a\`b\`c" +expect: + stdout: |+ + a`b`c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_dollar.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_dollar.yaml new file mode 100644 index 00000000..3b88a917 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_dollar.yaml @@ -0,0 +1,12 @@ +description: Backslash before dollar in double quotes escapes it to a literal dollar. +input: + script: |+ + echo "a\$b" + X=hello + echo "a\${X}b" +expect: + stdout: |+ + a$b + a${X}b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_nonspecial.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_nonspecial.yaml new file mode 100644 index 00000000..6469261e --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_backslash_nonspecial.yaml @@ -0,0 +1,9 @@ +description: Backslash in double quotes before non-special chars is preserved literally. +input: + script: |+ + echo "a\b" "a\c" "a\0" "a\x" "a\!" +expect: + stdout: |+ + a\b a\c a\0 a\x a\! + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_hash_not_comment.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_hash_not_comment.yaml new file mode 100644 index 00000000..ce27fcd6 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_hash_not_comment.yaml @@ -0,0 +1,11 @@ +description: Hash character inside double quotes is literal, not a comment. +input: + script: |+ + echo "# not a comment" + echo "before # after" +expect: + stdout: |+ + # not a comment + before # after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_multiline.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_multiline.yaml new file mode 100644 index 00000000..e0f15243 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_multiline.yaml @@ -0,0 +1,13 @@ +description: Double-quoted string can span multiple lines preserving newlines. +input: + script: |+ + echo "line1 + line2 + line3" +expect: + stdout: |+ + line1 + line2 + line3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_multiple_vars.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_multiple_vars.yaml new file mode 100644 index 00000000..e01e3a6b --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_multiple_vars.yaml @@ -0,0 +1,15 @@ +description: Multiple variables are expanded within double quotes. +input: + script: |+ + A=hello + B=world + echo "$A $B" + echo "${A}_${B}" + echo "$A$B" +expect: + stdout: |+ + hello world + hello_world + helloworld + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_no_glob.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_no_glob.yaml new file mode 100644 index 00000000..7803dfff --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_no_glob.yaml @@ -0,0 +1,9 @@ +description: Double quotes suppress glob expansion - asterisk, question mark, and brackets remain literal. +input: + script: |+ + echo "*.txt" "?.txt" "[abc]" +expect: + stdout: |+ + *.txt ?.txt [abc] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_operators_literal.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_operators_literal.yaml new file mode 100644 index 00000000..01878888 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_operators_literal.yaml @@ -0,0 +1,15 @@ +description: Semicolons and pipe characters inside double quotes are literal, not syntax. +input: + script: |+ + echo "a;b" + echo "a|b" + echo "a&&b" + echo "a||b" +expect: + stdout: |+ + a;b + a|b + a&&b + a||b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_preserves_single_quotes.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_preserves_single_quotes.yaml new file mode 100644 index 00000000..5c55de22 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_preserves_single_quotes.yaml @@ -0,0 +1,9 @@ +description: Double quotes preserve single quote characters literally. +input: + script: |+ + echo "'hello'" "'a'" +expect: + stdout: |+ + 'hello' 'a' + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_preserves_whitespace.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_preserves_whitespace.yaml new file mode 100644 index 00000000..b25e7fb7 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_preserves_whitespace.yaml @@ -0,0 +1,10 @@ +description: Double quotes preserve embedded tabs and multiple spaces. +input: + script: |+ + echo "a b" + echo "a b" + echo "a b" +expect: + stdout: "a b\na\tb\na \t b\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_unset_var.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_unset_var.yaml new file mode 100644 index 00000000..b39ff992 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_unset_var.yaml @@ -0,0 +1,11 @@ +description: Unset variable in double quotes expands to empty string, preserving surrounding text. +input: + script: |+ + echo "before${UNSET}after" + echo "a${UNSET}b${UNSET}c" +expect: + stdout: |+ + beforeafter + abc + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quote_with_unquoted.yaml b/tests/scenarios/shell/var_expand/quoting/double_quote_with_unquoted.yaml new file mode 100644 index 00000000..621dfa21 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quote_with_unquoted.yaml @@ -0,0 +1,11 @@ +description: Double-quoted segments adjacent to unquoted text form a single word. +input: + script: |+ + echo abc"def"ghi + echo "abc"def"ghi" +expect: + stdout: |+ + abcdefghi + abcdefghi + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quotes_backslash_rules.yaml b/tests/scenarios/shell/var_expand/quoting/double_quotes_backslash_rules.yaml new file mode 100644 index 00000000..dcd04cac --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quotes_backslash_rules.yaml @@ -0,0 +1,9 @@ +description: In double quotes only $ ` \ " and newline are special after backslash. +input: + script: |+ + echo "a\\b" "a\b" +expect: + stdout: |+ + a\b a\b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quotes_expand.yaml b/tests/scenarios/shell/var_expand/quoting/double_quotes_expand.yaml new file mode 100644 index 00000000..125f1bfb --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quotes_expand.yaml @@ -0,0 +1,10 @@ +description: Variables are expanded inside double quotes. +input: + script: |+ + NAME=world + echo "hello $NAME" +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quotes_line_continuation.yaml b/tests/scenarios/shell/var_expand/quoting/double_quotes_line_continuation.yaml new file mode 100644 index 00000000..23061021 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quotes_line_continuation.yaml @@ -0,0 +1,11 @@ +description: Line continuation works inside double quotes. +input: + script: |+ + echo "a\ + b\ + c" +expect: + stdout: |+ + abc + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/double_quotes_preserve_spaces.yaml b/tests/scenarios/shell/var_expand/quoting/double_quotes_preserve_spaces.yaml new file mode 100644 index 00000000..52d9c0ce --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/double_quotes_preserve_spaces.yaml @@ -0,0 +1,10 @@ +description: Double quotes preserve internal spaces in expanded variables. +input: + script: |+ + VAR="hello world" + echo "$VAR" +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/empty_quoted_args_preserved.yaml b/tests/scenarios/shell/var_expand/quoting/empty_quoted_args_preserved.yaml new file mode 100644 index 00000000..cfa22f14 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/empty_quoted_args_preserved.yaml @@ -0,0 +1,11 @@ +description: Empty quoted strings are preserved as arguments in for loop iteration. +input: + script: |+ + for w in "" "a" ""; do echo "[$w]"; done +expect: + stdout: |+ + [] + [a] + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/expansion_backslashes_literal.yaml b/tests/scenarios/shell/var_expand/quoting/expansion_backslashes_literal.yaml new file mode 100644 index 00000000..cb22b4b7 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/expansion_backslashes_literal.yaml @@ -0,0 +1,12 @@ +description: Variable value containing backslashes is printed literally when double-quoted. +input: + script: |+ + V='\a\b\c' + echo "$V" + echo $V +expect: + stdout: |+ + \a\b\c + \a\b\c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/mixed_adjacent_quotes.yaml b/tests/scenarios/shell/var_expand/quoting/mixed_adjacent_quotes.yaml new file mode 100644 index 00000000..5861c9a9 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/mixed_adjacent_quotes.yaml @@ -0,0 +1,12 @@ +description: Double-quoted and single-quoted segments can be mixed adjacent to form one word. +input: + script: |+ + X=hello + echo "pre_"'mid'"_$X" + echo 'a'"b"'c' +expect: + stdout: |+ + pre_mid_hello + abc + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/mixed_quoting.yaml b/tests/scenarios/shell/var_expand/quoting/mixed_quoting.yaml new file mode 100644 index 00000000..a4c824cb --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/mixed_quoting.yaml @@ -0,0 +1,10 @@ +description: Mixed quoting allows partial expansion in the same argument. +input: + script: |+ + NAME=world + echo 'literal '"$NAME"' literal' +expect: + stdout: |+ + literal world literal + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/quotes_in_value.yaml b/tests/scenarios/shell/var_expand/quoting/quotes_in_value.yaml new file mode 100644 index 00000000..118fbce8 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/quotes_in_value.yaml @@ -0,0 +1,13 @@ +description: Assignment values can contain quotes from the other quoting style. +input: + script: |+ + a='A"B"C' + b="A'B'C" + echo $a + echo $b +expect: + stdout: |+ + A"B"C + A'B'C + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_adjacent_words.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_adjacent_words.yaml new file mode 100644 index 00000000..d8d4e6cd --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_adjacent_words.yaml @@ -0,0 +1,11 @@ +description: Adjacent single-quoted segments form a single word without spaces. +input: + script: |+ + echo 'abc''def''ghi' + echo 'a''b''c''d' +expect: + stdout: |+ + abcdefghi + abcd + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_concat.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_concat.yaml new file mode 100644 index 00000000..9ef04b7e --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_concat.yaml @@ -0,0 +1,9 @@ +description: Adjacent single-quoted strings are concatenated. +input: + script: |+ + echo 'hel''lo' +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_empty.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_empty.yaml new file mode 100644 index 00000000..7f0b1304 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_empty.yaml @@ -0,0 +1,8 @@ +description: Single quotes preserve content literally with no escaping. +input: + script: |+ + echo '' 'a' 'a\\b' +expect: + stdout: " a a\\\\b\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_empty_arg.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_empty_arg.yaml new file mode 100644 index 00000000..d18f5412 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_empty_arg.yaml @@ -0,0 +1,11 @@ +description: Empty single-quoted string is a valid argument (not removed by field splitting). +input: + script: |+ + for w in '' 'a' ''; do echo "[$w]"; done +expect: + stdout: |+ + [] + [a] + [] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_hash_not_comment.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_hash_not_comment.yaml new file mode 100644 index 00000000..1385aee0 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_hash_not_comment.yaml @@ -0,0 +1,11 @@ +description: Hash inside single quotes is literal, not a comment. +input: + script: |+ + echo '# not a comment' + echo 'before # after' +expect: + stdout: |+ + # not a comment + before # after + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_multiline.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_multiline.yaml new file mode 100644 index 00000000..7900ee9b --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_multiline.yaml @@ -0,0 +1,13 @@ +description: Single-quoted string can span multiple lines preserving newlines literally. +input: + script: |+ + echo 'line1 + line2 + line3' +expect: + stdout: |+ + line1 + line2 + line3 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_no_var_expansion.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_no_var_expansion.yaml new file mode 100644 index 00000000..7bb6cd94 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_no_var_expansion.yaml @@ -0,0 +1,10 @@ +description: Variables inside single quotes are not expanded, even when set. +input: + script: |+ + X=hello + echo '$X' '${X}' +expect: + stdout: |+ + $X ${X} + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_operators_literal.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_operators_literal.yaml new file mode 100644 index 00000000..95aa7e63 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_operators_literal.yaml @@ -0,0 +1,15 @@ +description: Semicolons and pipe characters inside single quotes are literal, not syntax. +input: + script: |+ + echo 'a;b' + echo 'a|b' + echo 'a&&b' + echo 'a||b' +expect: + stdout: |+ + a;b + a|b + a&&b + a||b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_backslashes.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_backslashes.yaml new file mode 100644 index 00000000..f6031a66 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_backslashes.yaml @@ -0,0 +1,9 @@ +description: Single quotes preserve backslash sequences literally - no escape processing occurs. +input: + script: |+ + echo 'a\\b' 'a\\\\b' 'a\b\c' +expect: + stdout: |+ + a\\b a\\\\b a\b\c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_double_quotes.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_double_quotes.yaml new file mode 100644 index 00000000..972029d5 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_double_quotes.yaml @@ -0,0 +1,9 @@ +description: Single quotes preserve double quote characters literally. +input: + script: |+ + echo '"hello"' '"a"' +expect: + stdout: |+ + "hello" "a" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_whitespace.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_whitespace.yaml new file mode 100644 index 00000000..ffd49e3c --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_preserves_whitespace.yaml @@ -0,0 +1,9 @@ +description: Single quotes preserve tabs and multiple spaces literally. +input: + script: |+ + echo 'a b' + echo 'a b' +expect: + stdout: "a b\na\tb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_special_chars.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_special_chars.yaml new file mode 100644 index 00000000..7964dd02 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_special_chars.yaml @@ -0,0 +1,9 @@ +description: Single quotes preserve special characters literally including dollar, backtick, backslash, and glob chars. +input: + script: |+ + echo '$VAR' '\n' '\t' '`cmd`' '$(cmd)' '*' '?' '[abc]' +expect: + stdout: |+ + $VAR \n \t `cmd` $(cmd) * ? [abc] + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quote_with_unquoted.yaml b/tests/scenarios/shell/var_expand/quoting/single_quote_with_unquoted.yaml new file mode 100644 index 00000000..4b4fb987 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quote_with_unquoted.yaml @@ -0,0 +1,11 @@ +description: Single-quoted segments can be adjacent to unquoted text forming a single word. +input: + script: |+ + echo abc'def'ghi + echo 'abc'def'ghi' +expect: + stdout: |+ + abcdefghi + abcdefghi + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/quoting/single_quotes_no_expand.yaml b/tests/scenarios/shell/var_expand/quoting/single_quotes_no_expand.yaml new file mode 100644 index 00000000..fcf8e181 --- /dev/null +++ b/tests/scenarios/shell/var_expand/quoting/single_quotes_no_expand.yaml @@ -0,0 +1,10 @@ +description: Variables are NOT expanded inside single quotes. +input: + script: |+ + NAME=world + echo 'hello $NAME' +expect: + stdout: |+ + hello $NAME + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status.yaml b/tests/scenarios/shell/var_expand/special_variables/status.yaml new file mode 100644 index 00000000..4b439b87 --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status.yaml @@ -0,0 +1,13 @@ +description: The $? variable holds the exit status of the last command. +input: + script: |+ + true + echo $? + false + echo $? +expect: + stdout: |+ + 0 + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_after_and.yaml b/tests/scenarios/shell/var_expand/special_variables/status_after_and.yaml new file mode 100644 index 00000000..044cd10d --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_after_and.yaml @@ -0,0 +1,16 @@ +description: The $? variable after && reflects the last executed command. +input: + script: |+ + true && true + echo $? + true && false + echo $? + false && true + echo $? +expect: + stdout: |+ + 0 + 1 + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_after_assignment.yaml b/tests/scenarios/shell/var_expand/special_variables/status_after_assignment.yaml new file mode 100644 index 00000000..90572d82 --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_after_assignment.yaml @@ -0,0 +1,13 @@ +description: A simple variable assignment resets $? to 0. +input: + script: |+ + false + echo $? + a=1 + echo $? +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_after_echo.yaml b/tests/scenarios/shell/var_expand/special_variables/status_after_echo.yaml new file mode 100644 index 00000000..9cec019f --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_after_echo.yaml @@ -0,0 +1,11 @@ +description: The $? variable is 0 after a successful echo. +input: + script: |+ + echo hello + echo $? +expect: + stdout: |+ + hello + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_after_negation.yaml b/tests/scenarios/shell/var_expand/special_variables/status_after_negation.yaml new file mode 100644 index 00000000..5b694eaa --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_after_negation.yaml @@ -0,0 +1,13 @@ +description: The $? variable after negation reflects the inverted exit status. +input: + script: |+ + ! true + echo $? + ! false + echo $? +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_after_or.yaml b/tests/scenarios/shell/var_expand/special_variables/status_after_or.yaml new file mode 100644 index 00000000..65bf4c4b --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_after_or.yaml @@ -0,0 +1,16 @@ +description: The $? variable after || reflects the last executed command. +input: + script: |+ + true || false + echo $? + false || true + echo $? + false || false + echo $? +expect: + stdout: |+ + 0 + 0 + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_after_pipe.yaml b/tests/scenarios/shell/var_expand/special_variables/status_after_pipe.yaml new file mode 100644 index 00000000..0cea86ea --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_after_pipe.yaml @@ -0,0 +1,13 @@ +description: The $? variable reflects the exit status of the rightmost command in a pipeline. +input: + script: |+ + false | true + echo $? + true | false + echo $? +expect: + stdout: |+ + 0 + 1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_after_unknown_cmd.yaml b/tests/scenarios/shell/var_expand/special_variables/status_after_unknown_cmd.yaml new file mode 100644 index 00000000..fac0e872 --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_after_unknown_cmd.yaml @@ -0,0 +1,11 @@ +description: The $? variable is 127 after an unknown command. +input: + script: |+ + nonexistent_cmd + echo $? +expect: + stdout: |+ + 127 + stderr_contains: + - "not found" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_brace_syntax.yaml b/tests/scenarios/shell/var_expand/special_variables/status_brace_syntax.yaml new file mode 100644 index 00000000..52c23518 --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_brace_syntax.yaml @@ -0,0 +1,13 @@ +description: The $? variable works with brace syntax ${?}. +input: + script: |+ + false + echo "${?}" + true + echo "${?}" +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_capture_in_var.yaml b/tests/scenarios/shell/var_expand/special_variables/status_capture_in_var.yaml new file mode 100644 index 00000000..90a374eb --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_capture_in_var.yaml @@ -0,0 +1,13 @@ +description: The $? value can be captured into a variable before it is reset. +input: + script: |+ + false + A=$? + echo $A + echo $? +expect: + stdout: |+ + 1 + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_chained.yaml b/tests/scenarios/shell/var_expand/special_variables/status_chained.yaml new file mode 100644 index 00000000..eb8cf2ae --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_chained.yaml @@ -0,0 +1,15 @@ +description: The $? variable is updated after each command execution. +input: + script: |+ + true + A=$? + false + B=$? + true + C=$? + echo $A $B $C +expect: + stdout: |+ + 0 1 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_in_for_loop.yaml b/tests/scenarios/shell/var_expand/special_variables/status_in_for_loop.yaml new file mode 100644 index 00000000..4b2fb29a --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_in_for_loop.yaml @@ -0,0 +1,19 @@ +description: The $? variable is updated after each command in a for loop body. +input: + script: |+ + for x in a b c; do + true + echo "$x:$?" + done + false + for x in d; do + echo "$x:$?" + done +expect: + stdout: |+ + a:0 + b:0 + c:0 + d:1 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_in_string.yaml b/tests/scenarios/shell/var_expand/special_variables/status_in_string.yaml new file mode 100644 index 00000000..789ec508 --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_in_string.yaml @@ -0,0 +1,13 @@ +description: The $? variable can be used in string concatenation. +input: + script: |+ + true + echo "exit:$?" + false + echo "code=${?}!" +expect: + stdout: |+ + exit:0 + code=1! + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_initial.yaml b/tests/scenarios/shell/var_expand/special_variables/status_initial.yaml new file mode 100644 index 00000000..52340bd6 --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_initial.yaml @@ -0,0 +1,9 @@ +description: The $? variable is 0 at the start of a script. +input: + script: |+ + echo $? +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/var_expand/special_variables/status_multiple_captures.yaml b/tests/scenarios/shell/var_expand/special_variables/status_multiple_captures.yaml new file mode 100644 index 00000000..d643e257 --- /dev/null +++ b/tests/scenarios/shell/var_expand/special_variables/status_multiple_captures.yaml @@ -0,0 +1,15 @@ +description: The $? value can be captured into multiple variables across a sequence of commands. +input: + script: |+ + true + R1=$? + false + R2=$? + true + R3=$? + echo "$R1 $R2 $R3" +expect: + stdout: |+ + 0 1 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios_test.go b/tests/scenarios_test.go new file mode 100644 index 00000000..422e59ef --- /dev/null +++ b/tests/scenarios_test.go @@ -0,0 +1,392 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +package tests + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/datadog-agent/pkg/shell/interp" +) + +const dockerBashImage = "bash:5.2" + +// scenario represents a single test scenario. +type scenario struct { + Description string `yaml:"description"` + TargetOS []string `yaml:"target_os"` // if set, only run on these OS (linux, darwin, windows); empty means all + TestAgainstLocalShell *bool `yaml:"test_against_local_shell"` // nil = true (default); false = skip bash comparison + Setup setup `yaml:"setup"` + Input input `yaml:"input"` + Expect expected `yaml:"expect"` +} + +// setup holds optional pre-test configuration such as files to create. +type setup struct { + Files []setupFile `yaml:"files"` +} + +// setupFile describes a file to create before executing the scenario. +// When Symlink is set, a symbolic link is created instead of a regular file. +type setupFile struct { + Path string `yaml:"path"` + Content string `yaml:"content"` + Chmod os.FileMode `yaml:"chmod"` + Symlink string `yaml:"symlink"` // if set, create a symlink pointing to this target (relative to test dir) +} + +// input holds the shell script to execute. +type input struct { + // Envs sets OS-level environment variables for the bash comparison test + // only. These are intentionally NOT passed to the restricted interpreter, + // which starts with an empty environment for security (no host env inheritance). + Envs map[string]string `yaml:"envs"` + // InterpreterEnv sets initial environment variables for the restricted + // interpreter via the Env RunnerOption. These are passed as "KEY=value" pairs. + InterpreterEnv map[string]string `yaml:"interpreter_env"` + Script string `yaml:"script"` + AllowedPaths []string `yaml:"allowed_paths"` // relative to test temp dir; "$DIR" resolves to temp dir itself +} + +// expected holds the expected output for a scenario. +type expected struct { + Stdout string `yaml:"stdout"` + StdoutContains []string `yaml:"stdout_contains"` + Stderr string `yaml:"stderr"` + StderrContains []string `yaml:"stderr_contains"` + ExitCode int `yaml:"exit_code"` +} + +// discoverScenarioFiles walks the scenarios directory and returns all YAML files +// grouped by their relative directory path. +func discoverScenarioFiles(t *testing.T, scenariosDir string) map[string][]string { + t.Helper() + files := make(map[string][]string) + err := filepath.Walk(scenariosDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if filepath.Ext(path) != ".yaml" && filepath.Ext(path) != ".yml" { + return nil + } + rel, err := filepath.Rel(scenariosDir, path) + if err != nil { + return err + } + group := filepath.Dir(rel) + files[group] = append(files[group], path) + return nil + }) + require.NoError(t, err, "failed to walk scenarios directory") + return files +} + +// loadScenario parses a YAML file into a single scenario. +func loadScenario(t *testing.T, path string) scenario { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err, "failed to read scenario file %s", path) + + var sc scenario + err = yaml.Unmarshal(data, &sc) + require.NoError(t, err, "failed to parse scenario file %s", path) + return sc +} + +// setupTestDir creates a temporary directory and populates it with any files +// defined in the scenario's setup section. It returns the path to the temp dir. +func setupTestDir(t *testing.T, sc scenario) string { + t.Helper() + dir := t.TempDir() + for _, f := range sc.Setup.Files { + fullPath := filepath.Join(dir, f.Path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0755), "failed to create directories for %s", f.Path) + if f.Symlink != "" { + // Create a symbolic link. The target is used as-is (relative to the link's location). + require.NoError(t, os.Symlink(f.Symlink, fullPath), "failed to create symlink %s -> %s", f.Path, f.Symlink) + } else { + require.NoError(t, os.WriteFile(fullPath, []byte(f.Content), 0644), "failed to write file %s", f.Path) + if f.Chmod != 0 { + require.NoError(t, os.Chmod(fullPath, f.Chmod), "failed to chmod file %s", f.Path) + } + } + } + return dir +} + +// runScenario executes a single test scenario against the shell interpreter +// and asserts the expected output. +func runScenario(t *testing.T, sc scenario) { + t.Helper() + + if len(sc.TargetOS) > 0 { + matched := false + for _, goos := range sc.TargetOS { + if goos == runtime.GOOS { + matched = true + break + } + } + if !matched { + t.Skipf("skipping: scenario targets %v, current GOOS is %s", sc.TargetOS, runtime.GOOS) + } + } + + dir := setupTestDir(t, sc) + + parser := syntax.NewParser() + prog, err := parser.Parse(strings.NewReader(sc.Input.Script), "") + require.NoError(t, err, "failed to parse script") + + var stdout, stderr bytes.Buffer + opts := []interp.RunnerOption{ + interp.StdIO(nil, &stdout, &stderr), + } + if len(sc.Input.InterpreterEnv) > 0 { + pairs := make([]string, 0, len(sc.Input.InterpreterEnv)) + for k, v := range sc.Input.InterpreterEnv { + pairs = append(pairs, k+"="+v) + } + opts = append(opts, interp.Env(pairs...)) + } + if sc.Input.AllowedPaths != nil { + resolved := make([]string, len(sc.Input.AllowedPaths)) + for i, p := range sc.Input.AllowedPaths { + if p == "$DIR" { + resolved[i] = dir + } else { + resolved[i] = filepath.Join(dir, p) + } + } + opts = append(opts, interp.AllowedPaths(resolved)) + } + runner, err := interp.New(opts...) + require.NoError(t, err, "failed to create runner") + defer runner.Close() + + runner.Dir = dir + + err = runner.Run(context.Background(), prog) + + // Extract exit code from error. + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + + assertExpectations(t, sc, stdout.String(), stderr.String(), exitCode) +} + +// assertExpectations checks stdout, stderr, and exit code against the scenario expectations. +func assertExpectations(t *testing.T, sc scenario, stdout, stderr string, exitCode int) { + t.Helper() + + assert.Equal(t, sc.Expect.ExitCode, exitCode, "exit code mismatch") + if len(sc.Expect.StdoutContains) > 0 { + for _, substr := range sc.Expect.StdoutContains { + assert.Contains(t, stdout, substr, "stdout should contain %q", substr) + } + } else { + assert.Equal(t, sc.Expect.Stdout, stdout, "stdout mismatch") + } + if len(sc.Expect.StderrContains) > 0 { + for _, substr := range sc.Expect.StderrContains { + assert.Contains(t, stderr, substr, "stderr should contain %q", substr) + } + } else { + assert.Equal(t, sc.Expect.Stderr, stderr, "stderr mismatch") + } +} + +// dockerScenario associates a scenario with its test name and subdirectory +// inside the shared Docker mount. +type dockerScenario struct { + testName string // e.g. "cmd/echo/basic" + subdir string // e.g. "s42" + sc scenario +} + +// targetsLinux returns true if the scenario should run in a Linux Docker container. +func targetsLinux(sc scenario) bool { + if len(sc.TargetOS) == 0 { + return true + } + for _, goos := range sc.TargetOS { + if goos == "linux" { + return true + } + } + return false +} + +// setupTestDirIn creates a subdirectory named subdir inside parentDir and +// populates it with the scenario's setup files. The script is written to +// scriptsDir/.sh so it doesn't pollute the working directory (which +// would break glob-based scenarios). +func setupTestDirIn(t *testing.T, parentDir, scriptsDir, subdir string, sc scenario) { + t.Helper() + dir := filepath.Join(parentDir, subdir) + require.NoError(t, os.MkdirAll(dir, 0755)) + for _, f := range sc.Setup.Files { + fullPath := filepath.Join(dir, f.Path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0755), "failed to create directories for %s", f.Path) + if f.Symlink != "" { + require.NoError(t, os.Symlink(f.Symlink, fullPath), "failed to create symlink %s -> %s", f.Path, f.Symlink) + } else { + require.NoError(t, os.WriteFile(fullPath, []byte(f.Content), 0644), "failed to write file %s", f.Path) + if f.Chmod != 0 { + require.NoError(t, os.Chmod(fullPath, f.Chmod), "failed to chmod file %s", f.Path) + } + } + } + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, subdir+".sh"), []byte(sc.Input.Script), 0644)) +} + +// buildRunnerScript generates a bash script that executes all scenarios and +// writes results (stdout, stderr, exit code) to /work/results/. +// Scripts live in /work/scripts/.sh, separate from the working dirs. +func buildRunnerScript(scenarios []dockerScenario) string { + var b strings.Builder + b.WriteString("#!/bin/bash\nmkdir -p /work/results\n") + for _, ds := range scenarios { + var envParts []string + for k, v := range ds.sc.Input.Envs { + envParts = append(envParts, fmt.Sprintf("export %s=%s;", k, shellQuote(v))) + } + envPrefix := "" + if len(envParts) > 0 { + envPrefix = strings.Join(envParts, " ") + " " + } + fmt.Fprintf(&b, + "( cd /work/%s && %sbash /work/scripts/%s.sh ) >'/work/results/%s.stdout' 2>'/work/results/%s.stderr'; echo $? >'/work/results/%s.ec'\n", + ds.subdir, envPrefix, ds.subdir, ds.subdir, ds.subdir, ds.subdir, + ) + } + return b.String() +} + +// shellQuote returns a single-quoted shell string, escaping any embedded single quotes. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func TestShellScenariosAgainstBash(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found, skipping bash comparison tests") + } + // Pull the image once before starting the container. + pull := exec.Command("docker", "pull", "-q", dockerBashImage) + if out, err := pull.CombinedOutput(); err != nil { + t.Skipf("failed to pull %s docker image: %v\n%s", dockerBashImage, err, out) + } + + // Create a shared temp directory that will be bind-mounted into the container. + sharedDir := t.TempDir() + + // --- Phase 1: collect all eligible scenarios and write their files --- + scenariosDir := filepath.Join("scenarios") + groups := discoverScenarioFiles(t, scenariosDir) + require.NotEmpty(t, groups, "no scenario files found in %s", scenariosDir) + + scriptsDir := filepath.Join(sharedDir, "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0755)) + + var allScenarios []dockerScenario + seq := 0 + for group, paths := range groups { + for _, path := range paths { + sc := loadScenario(t, path) + if sc.TestAgainstLocalShell != nil && !*sc.TestAgainstLocalShell { + continue + } + if !targetsLinux(sc) { + continue + } + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + subdir := fmt.Sprintf("s%d", seq) + seq++ + setupTestDirIn(t, sharedDir, scriptsDir, subdir, sc) + allScenarios = append(allScenarios, dockerScenario{ + testName: group + "/" + name, + subdir: subdir, + sc: sc, + }) + } + } + require.NotEmpty(t, allScenarios, "no eligible scenarios found") + + // --- Phase 2: run ALL scenarios in a single docker invocation --- + runnerScript := buildRunnerScript(allScenarios) + runnerPath := filepath.Join(sharedDir, "runner.sh") + require.NoError(t, os.WriteFile(runnerPath, []byte(runnerScript), 0755)) + + cmd := exec.Command("docker", "run", "--rm", + "-v", sharedDir+":/work", + dockerBashImage, "bash", "/work/runner.sh", + ) + var dockerStderr bytes.Buffer + cmd.Stderr = &dockerStderr + require.NoError(t, cmd.Run(), "runner script failed: %s", dockerStderr.String()) + + // --- Phase 3: read results and assert per-scenario expectations --- + resultsDir := filepath.Join(sharedDir, "results") + for _, ds := range allScenarios { + t.Run(ds.testName, func(t *testing.T) { + stdout, err := os.ReadFile(filepath.Join(resultsDir, ds.subdir+".stdout")) + require.NoError(t, err, "missing stdout for %s", ds.testName) + stderr, err := os.ReadFile(filepath.Join(resultsDir, ds.subdir+".stderr")) + require.NoError(t, err, "missing stderr for %s", ds.testName) + ecBytes, err := os.ReadFile(filepath.Join(resultsDir, ds.subdir+".ec")) + require.NoError(t, err, "missing exit code for %s", ds.testName) + exitCode, err := strconv.Atoi(strings.TrimSpace(string(ecBytes))) + require.NoError(t, err, "invalid exit code for %s: %q", ds.testName, string(ecBytes)) + + assertExpectations(t, ds.sc, string(stdout), string(stderr), exitCode) + }) + } +} + +func TestShellScenarios(t *testing.T) { + scenariosDir := filepath.Join("scenarios") + groups := discoverScenarioFiles(t, scenariosDir) + require.NotEmpty(t, groups, "no scenario files found in %s", scenariosDir) + + for group, paths := range groups { + t.Run(group, func(t *testing.T) { + t.Parallel() + for _, path := range paths { + sc := loadScenario(t, path) + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + t.Run(name, func(t *testing.T) { + t.Parallel() + runScenario(t, sc) + }) + } + }) + } +}