diff --git a/CHANGELOG.md b/CHANGELOG.md index bd18ca5..5908eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.9.8] - 2026-11-08 + +### Fixed +- Fixed script path and parameter splitting when workspace-relative paths contain spaces: commands are now safely built with `shlex.quote()` on all script targets and argument tokens. + +### Added +- Added script positional arguments support via CLI `--args`, configuration `"args": [...]`, and Composite Action input `args`. +- Added quiet summary mode via CLI `-q` / `--quiet`, configuration `"quiet": true`, and Composite Action input `quiet` to print a compact single-line result when all checks pass. +- Added dedicated remediation hints for Alpine missing `dash` and Debian/Ubuntu missing `ash`. + ## [0.9.7] - 2026-11-08 ### Added diff --git a/README.md b/README.md index 05315a9..b02cfa6 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ apt-get --version Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI: ```yaml -- uses: Mresyzz/opsscript-gate@v0.9.7 +- uses: Mresyzz/opsscript-gate@v0.9.8 with: script-path: install.sh ``` @@ -57,15 +57,17 @@ The result identifies the failing distribution, exit code, line, missing command --- -## Current release: v0.9.7 +## Current release: v0.9.8 -The current release includes fail-fast execution (`-x` / `--fail-fast`), native `dash` and `ash` shebang support, host workspace context mounting (`--workdir`) with relative script `$0` path preservation, safe environment variable passing (`--env`), and expanded package recommendations. +The current release includes script positional arguments passing (`--args`), quiet summary mode (`-q` / `--quiet`), workspace path space-safe execution, fail-fast execution (`-x` / `--fail-fast`), native `dash` and `ash` shebang support with dedicated remediation, host workspace mounting (`--workdir`), and safe environment variable passing (`--env`). ```bash opsscript-gate init opsscript-gate doctor opsscript-gate run --dry-run opsscript-gate run --format json --output reports/compatibility.json +# Pass arguments to the target script and run in quiet mode +opsscript-gate run ./deploy.sh --args "staging --dry-run" -q # Stop immediately on the first failed check opsscript-gate run -x # Pass environment variables and mount repository workspace read-only @@ -114,7 +116,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.7 + - uses: Mresyzz/opsscript-gate@v0.9.8 ``` This zero-configuration form discovers shell scripts in the repository. Use @@ -125,7 +127,7 @@ repository has a single installer. For self-hosted model installers, see the Or test a specific script with custom execution modes: ```yaml - - uses: Mresyzz/opsscript-gate@v0.9.7 + - uses: Mresyzz/opsscript-gate@v0.9.8 with: script-path: scripts/install.sh shell: auto @@ -159,7 +161,7 @@ unrelated scripts: with: persist-credentials: false fetch-depth: 0 - - uses: Mresyzz/opsscript-gate@v0.9.7 + - uses: Mresyzz/opsscript-gate@v0.9.8 with: changed-since: ${{ github.event.pull_request.base.sha }} preset: minimal @@ -250,7 +252,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another SARIF-compatible viewer. Upload it explicitly with the official upload action: ```yaml -- uses: Mresyzz/opsscript-gate@v0.9.7 +- uses: Mresyzz/opsscript-gate@v0.9.8 with: script-path: scripts/install.sh format: sarif diff --git a/README.zh-CN.md b/README.zh-CN.md index 6448a57..10746ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,9 +17,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。 也可以直接打开 [OpsScript Gate 在线演示](https://mresyzz.github.io/opsscript-gate/),先查看 ShellCheck 与运行时验证的差异,再复制 workflow 到自己的仓库。 -## 当前版本 v0.9.7 +## 当前版本 v0.9.8 -v0.9.7 是当前已发布版本;如果你要从源码验证当前分支,也可以执行: +v0.9.8 是当前已发布版本;如果你要从源码验证当前分支,也可以执行: ```bash pip install -e . @@ -33,11 +33,13 @@ opsscript-gate init opsscript-gate doctor opsscript-gate run --dry-run opsscript-gate run --format json --output reports/compatibility.json +# 向目标脚本安全传参并启用静默模式 +opsscript-gate run ./deploy.sh --args "staging --dry-run" -q # 开启失败即停(-x / --fail-fast),传递环境变量并只读挂载工作区 opsscript-gate run ./install.sh -x --env APP_ENV=staging --workdir . ``` -`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.7`。 +`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.8`。 预览不需要 Docker;真实运行需要 Python 3.10+ 和可访问的 Linux Docker 引擎。 `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。 @@ -76,7 +78,7 @@ Docker daemon 和可选的项目配置。 - uses: actions/checkout@v7 with: fetch-depth: 0 -- uses: Mresyzz/opsscript-gate@v0.9.7 +- uses: Mresyzz/opsscript-gate@v0.9.8 with: changed-since: ${{ github.event.pull_request.base.sha }} preset: minimal diff --git a/action.yml b/action.yml index 976d861..d5cedc3 100644 --- a/action.yml +++ b/action.yml @@ -83,6 +83,14 @@ inputs: description: 'Stop immediately on the first failed distribution or script check (default: false)' required: false default: 'false' + args: + description: 'Arguments string to pass to the target script (e.g. "staging --dry-run")' + required: false + default: '' + quiet: + description: 'Quiet mode: print compact summary on success (default: false)' + required: false + default: 'false' output: description: 'Save a report file for upload-artifact or other tooling' required: false @@ -136,6 +144,8 @@ runs: INPUT_MAX_SCRIPTS: ${{ inputs.max-scripts }} INPUT_DRY_RUN: ${{ inputs.dry-run }} INPUT_FAIL_FAST: ${{ inputs.fail-fast }} + INPUT_ARGS: ${{ inputs.args }} + INPUT_QUIET: ${{ inputs.quiet }} INPUT_OUTPUT: ${{ inputs.output }} INPUT_CHANGED_SINCE: ${{ inputs.changed-since }} run: | @@ -160,6 +170,7 @@ runs: [ -z "$INPUT_MAX_SCRIPTS" ] || ARGS+=(--max-scripts "$INPUT_MAX_SCRIPTS") [ -z "$INPUT_OUTPUT" ] || ARGS+=(--output "$INPUT_OUTPUT") [ -z "$INPUT_CHANGED_SINCE" ] || ARGS+=(--changed-since "$INPUT_CHANGED_SINCE") + [ -z "$INPUT_ARGS" ] || ARGS+=(--args "$INPUT_ARGS") while IFS= read -r env_var; do env_var="${env_var%$'\r'}" [ -z "$env_var" ] || ARGS+=("--env" "$env_var") @@ -175,6 +186,13 @@ runs: echo "fail-fast must be true or false" >&2 exit 1 fi + QUIET_VALUE="$(printf '%s' "$INPUT_QUIET" | tr '[:upper:]' '[:lower:]')" + if [ "$QUIET_VALUE" = "true" ]; then + ARGS+=("--quiet") + elif [ "$QUIET_VALUE" != "false" ]; then + echo "quiet must be true or false" >&2 + exit 1 + fi DRY_RUN_VALUE="$(printf '%s' "$INPUT_DRY_RUN" | tr '[:upper:]' '[:lower:]')" if [ "$DRY_RUN_VALUE" = "true" ]; then ARGS+=("--dry-run") diff --git a/pyproject.toml b/pyproject.toml index a726dc4..3a0610b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsscript-gate" -version = "0.9.7" +version = "0.9.8" description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found." readme = "README.md" license = { text = "MIT" } diff --git a/src/opsscript_gate/__init__.py b/src/opsscript_gate/__init__.py index b9a2068..5e330ca 100644 --- a/src/opsscript_gate/__init__.py +++ b/src/opsscript_gate/__init__.py @@ -1,4 +1,4 @@ """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts.""" -__version__ = "0.9.7" +__version__ = "0.9.8" __all__ = ["__version__"] diff --git a/src/opsscript_gate/cli.py b/src/opsscript_gate/cli.py index 54d995a..f8ff2e1 100644 --- a/src/opsscript_gate/cli.py +++ b/src/opsscript_gate/cli.py @@ -3,6 +3,7 @@ import argparse import json import os +import shlex import sys import time from pathlib import Path @@ -18,6 +19,7 @@ PRESETS, init_project, load_config, + validate_args, validate_env, validate_memory_limit, validate_packages, @@ -175,6 +177,18 @@ def build_parser() -> argparse.ArgumentParser: default=False, help="Stop immediately on the first failed distribution or script check", ) + run_parser.add_argument( + "--args", + type=str, + default=None, + help="Arguments string or repeated arguments to pass to the script under test", + ) + run_parser.add_argument( + "-q", "--quiet", + action="store_true", + default=False, + help="Quiet mode: on all checks passed, only print a compact single line summary", + ) run_parser.add_argument( "--changed-since", help="Only test shell scripts changed between this Git revision and HEAD", @@ -258,6 +272,7 @@ def run_checked_matrix( env=args.env, workdir=args.workdir, fail_fast=getattr(args, "fail_fast", False), + args=getattr(args, "args", None), ) except DockerDaemonError as err: sys.stderr.write(f"Docker Error: {err}\n") @@ -359,6 +374,8 @@ def main(argv: list[str] | None = None) -> int: explicit.add("--jobs") if any(token == "-x" or token.startswith("-x") for token in argv): explicit.add("--fail-fast") + if any(token == "-q" or token.startswith("-q") for token in argv): + explicit.add("--quiet") for key in DEFAULTS: if key in config and "--" + key.replace("_", "-") not in explicit: setattr(args, key, config[key]) @@ -386,6 +403,11 @@ def main(argv: list[str] | None = None) -> int: args.env = parse_env_arguments(args.env, base_env=base_env) if args.workdir: args.workdir = validate_workdir(args.workdir) + if args.args: + args.args = validate_args(args.args) + elif not isinstance(args.args, list): + args.args = [] + args.quiet = bool(args.quiet) if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")): raise ValueError("matrix must contain at least one image") except ValueError as err: @@ -456,12 +478,14 @@ def main(argv: list[str] | None = None) -> int: "executions": len(target_scripts) * len(matrix) * args.repeat, "shell": args.shell, "network": args.network, "packages": args.packages, "timeout": args.timeout, "repeat": args.repeat, "env": args.env, "workdir": args.workdir, - "fail_fast": args.fail_fast} + "fail_fast": args.fail_fast, "args": args.args, "quiet": args.quiet} if changed_selection: plan["changed_since"] = args.changed_since env_preview = f"\nEnv: {', '.join(f'{k}={v}' for k, v in args.env.items())}" if args.env else "" workdir_preview = f"\nWorkdir: {args.workdir}" if args.workdir else "" fail_fast_preview = f"\nFail-Fast: {args.fail_fast}" if args.fail_fast else "" + args_preview = f"\nArgs: {' '.join(shlex.quote(a) for a in args.args)}" if args.args else "" + quiet_preview = f"\nQuiet: {args.quiet}" if args.quiet else "" output = json.dumps(plan, indent=2) if args.format == "json" else ( "Execution preview (no scripts executed)\n" + "\n".join(f" {path}" for path in target_scripts) @@ -470,6 +494,8 @@ def main(argv: list[str] | None = None) -> int: + env_preview + workdir_preview + fail_fast_preview + + args_preview + + quiet_preview + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s | Repeat: {args.repeat}" ) return 0 if publish_output(output, args.output) else 1 @@ -507,7 +533,11 @@ def main(argv: list[str] | None = None) -> int: else: output = format_terminal_table(report, script_path=script_path) - if not publish_output(output, args.output): + quiet_summary = None + if args.quiet and report.all_passed: + quiet_summary = f"OpsScript Gate: All {len(report.results)} distributions passed ({report.total_duration:.2f}s)" + + if not publish_output(output, args.output, quiet_summary=quiet_summary): return 1 # Emit GitHub line annotations if in CI @@ -552,7 +582,11 @@ def main(argv: list[str] | None = None) -> int: else: output = format_multi_terminal_table(multi_report) - if not publish_output(output, args.output): + quiet_summary = None + if args.quiet and multi_report.all_passed: + quiet_summary = f"OpsScript Gate: All {len(multi_report.reports)} scripts passed ({multi_report.total_duration:.2f}s)" + + if not publish_output(output, args.output, quiet_summary=quiet_summary): return 1 write_github_step_summary(multi_report) @@ -562,9 +596,16 @@ def main(argv: list[str] | None = None) -> int: return 0 -def publish_output(output: str, filename: str | None) -> bool: +def publish_output( + output: str, + filename: str | None, + quiet_summary: str | None = None, +) -> bool: # Always keep the terminal result visible even when persistence fails. - print(output) + if quiet_summary is not None: + print(quiet_summary) + else: + print(output) if filename: try: path = Path(filename) diff --git a/src/opsscript_gate/config.py b/src/opsscript_gate/config.py index a826a81..3f2fe87 100644 --- a/src/opsscript_gate/config.py +++ b/src/opsscript_gate/config.py @@ -3,6 +3,7 @@ import json import re +import shlex from pathlib import Path from docker.errors import DockerException @@ -18,17 +19,44 @@ "shell": "posix", "mem_limit": "256m", "pids_limit": 128, "network": "bridge", "packages": [], "exclude": [], "max_scripts": 20, "repeat": 1, "env": {}, "workdir": None, "fail_fast": False, + "args": [], "quiet": False, } MAX_CONFIG_SIZE_BYTES = 256 * 1024 MAX_REPEAT_ATTEMPTS = 20 MAX_TIMEOUT_SECONDS = 3600 MAX_MATRIX_IMAGES = 32 +MAX_ARGS_COUNT = 128 +MAX_ARG_LENGTH = 4096 PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._:-]*$") ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +def validate_args(value: object) -> list[str]: + """Validate and normalize positional arguments passed to the script under test.""" + if isinstance(value, str): + try: + tokens = shlex.split(value) + except ValueError as exc: + raise ValueError(f"Invalid args string syntax: {exc}") from exc + elif isinstance(value, (list, tuple)): + if not all(isinstance(a, str) for a in value): + raise ValueError("args list must contain only strings") + tokens = list(value) + else: + raise ValueError("args must be a string or a list of argument strings") + + if len(tokens) > MAX_ARGS_COUNT: + raise ValueError(f"args must not contain more than {MAX_ARGS_COUNT} arguments") + + for idx, arg in enumerate(tokens): + if len(arg) > MAX_ARG_LENGTH: + raise ValueError(f"arg at index {idx} exceeds maximum length of {MAX_ARG_LENGTH} characters") + + return tokens + + def validate_env(value: object) -> dict[str, str]: """Validate and normalize environment variables specified in configuration or CLI.""" if not isinstance(value, dict): @@ -133,6 +161,11 @@ def load_config(filename: str | None) -> dict: elif key == "fail_fast": if not isinstance(value, bool): raise ValueError("fail_fast must be a boolean") + elif key == "args": + config[key] = validate_args(value) + elif key == "quiet": + if not isinstance(value, bool): + raise ValueError("quiet must be a boolean") elif not isinstance(value, str) or not value.strip(): raise ValueError(f"{key} must be a nonempty string") for key, choices in {"shell": ("posix", "auto", "shebang"), @@ -166,7 +199,7 @@ def init_project() -> list[str]: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.7 + - uses: Mresyzz/opsscript-gate@v0.9.8 with: config: .opsscript-gate.json """, diff --git a/src/opsscript_gate/remediation.py b/src/opsscript_gate/remediation.py index a9f4ef1..eb39ef2 100644 --- a/src/opsscript_gate/remediation.py +++ b/src/opsscript_gate/remediation.py @@ -72,6 +72,18 @@ def _match_alpine_bash(distro: str | None, command: str | None, kind: str) -> bo return "alpine" in distro.lower() and _normalize_cmd_basename(command) == "bash" +def _match_alpine_dash(distro: str | None, command: str | None, kind: str) -> bool: + if not distro or not command: + return False + return detect_distro_family(distro) == "alpine" and _normalize_cmd_basename(command) == "dash" + + +def _match_debian_ash(distro: str | None, command: str | None, kind: str) -> bool: + if not distro or not command: + return False + return detect_distro_family(distro) == "debian" and _normalize_cmd_basename(command) == "ash" + + def _match_curl(distro: str | None, command: str | None, kind: str) -> bool: return _normalize_cmd_basename(command) == "curl" @@ -232,6 +244,16 @@ def resolve_package_recommendation( matcher=_match_alpine_bash, hint="Alpine minimal images do not include Bash by default; consider using POSIX sh or installing bash.", ), + RemediationRule( + rule_id="alpine_dash", + matcher=_match_alpine_dash, + hint="Alpine Linux minimal images use busybox ash by default and do not include dash; consider using POSIX sh or installing dash.", + ), + RemediationRule( + rule_id="debian_ash", + matcher=_match_debian_ash, + hint="Debian/Ubuntu distributions use dash as /bin/sh and do not provide standalone ash by default; consider using /bin/sh or installing ash.", + ), RemediationRule( rule_id="missing_curl", matcher=_match_curl, diff --git a/src/opsscript_gate/runner.py b/src/opsscript_gate/runner.py index 1f1a6b3..fdc8c5b 100644 --- a/src/opsscript_gate/runner.py +++ b/src/opsscript_gate/runner.py @@ -22,6 +22,7 @@ MAX_REPEAT_ATTEMPTS, MAX_TIMEOUT_SECONDS, PACKAGE_NAME_RE, + validate_args, validate_env, validate_memory_limit, validate_packages, @@ -65,6 +66,40 @@ DEFAULT_POSIX_COMMAND: list[str] = ["/bin/sh", "-c", "/bin/sh /tmp/target_script.sh list[str]: + """ + Construct safe container execution command replacing default target script + with properly shell-quoted script_target and optional positional arguments. + """ + if not base_command: + return ["/bin/sh", "-c", f"/bin/sh {shlex.quote(script_target)} = 3 and cmd_list[0] == "/bin/sh" and cmd_list[1] == "-c": + shell_str = cmd_list[2] + if " /tmp/target_script.sh str | None: """Validate execution options shared by the public runner entry points.""" if isinstance(packages, (str, bytes)): @@ -885,6 +921,8 @@ def _runtime_option_error( validate_env(env) if workdir is not None: validate_workdir(workdir) + if args is not None: + validate_args(args) except (TypeError, ValueError) as exc: return str(exc) return None @@ -923,6 +961,7 @@ def run_on_distro( prepared_script_path: str | None = None, env: dict[str, str] | None = None, workdir: str | None = None, + args: Sequence[str] | None = None, ) -> SingleResult: """ Run a target script inside an unprivileged, non-interactive container. @@ -938,7 +977,7 @@ def run_on_distro( error_message="packages must be a list of package names", ) package_list = list(packages or []) - option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir) + option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir, args=args) if option_error: return SingleResult( distro=distro, @@ -948,6 +987,7 @@ def run_on_distro( output_snippet="", error_message=option_error, ) + parsed_args = validate_args(args) if args else [] try: poll_interval_value = float(poll_interval) except (TypeError, ValueError, OverflowError): @@ -1049,7 +1089,10 @@ def run_on_distro( if rel_script is not None and not rel_script.startswith("..") and not os.path.isabs(rel_script): posix_rel = rel_script.replace("\\", "/") target_bind_path = f"/workspace/{posix_rel}" - command = [part.replace("/tmp/target_script.sh", target_bind_path) for part in command] + + command = build_container_execution_command( + command, target_bind_path, args=parsed_args + ) volumes[safe_mount_src] = { "bind": target_bind_path, @@ -1276,6 +1319,7 @@ def run_matrix( env: dict[str, str] | None = None, workdir: str | None = None, fail_fast: bool = False, + args: Sequence[str] | None = None, ) -> RunReport: """ Run the compatibility check across all specified Linux distributions, @@ -1335,10 +1379,11 @@ def run_matrix( if isinstance(packages, (str, bytes)): raise ValueError("packages must be a list of package names") package_list = list(packages or []) - option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir) + option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir, args=args) if option_error: raise ValueError(option_error) package_list = validate_packages(package_list) + parsed_args = validate_args(args) if args else [] mem_limit = validate_memory_limit(mem_limit) if package_list and network == "none": raise ValueError("Package setup requires network mode 'bridge'") @@ -1389,6 +1434,7 @@ def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]: prepared_script_path=prepared_script, env=env, workdir=workdir, + args=parsed_args, ) attempts.append(res_attempt) if fail_fast and res_attempt.status != DistroStatus.PASS: diff --git a/tests/test_action_contract.py b/tests/test_action_contract.py index a8643dd..1a20fa8 100644 --- a/tests/test_action_contract.py +++ b/tests/test_action_contract.py @@ -9,12 +9,12 @@ def test_action_metadata_is_valid_and_inputs_are_wired(): text = (ROOT / "action.yml").read_text(encoding="utf-8") assert "using: 'composite'" in text - for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir", "fail-fast"): + for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir", "fail-fast", "args", "quiet"): assert f" {name}:" in text run_text = text for flag in ("--timeout", "--format", "--shell", "--mem-limit", "--pids-limit", "--network", "--packages", "--matrix", "--jobs", "--config", "--preset", "--repeat", - "--max-scripts", "--output", "--exclude", "--env", "--workdir", "--fail-fast"): + "--max-scripts", "--output", "--exclude", "--env", "--workdir", "--fail-fast", "--args", "--quiet"): assert flag in run_text assert "json, or sarif" in text assert "--changed-since" in run_text diff --git a/tests/test_audit_coverage.py b/tests/test_audit_coverage.py index 5b4dc9f..9e9dcdd 100644 --- a/tests/test_audit_coverage.py +++ b/tests/test_audit_coverage.py @@ -1075,4 +1075,214 @@ def test_config_fail_fast_setting(tmp_path, capsys): assert plan["fail_fast"] is True +def test_validate_args_valid_and_invalid(): + from opsscript_gate.config import validate_args, MAX_ARGS_COUNT, MAX_ARG_LENGTH + import pytest + + # 1. 字符串正常拆分(带空格与引号) + res1 = validate_args("staging --dry-run 'cluster prod' \"user 1\"") + assert res1 == ["staging", "--dry-run", "cluster prod", "user 1"] + + # 2. 列表正常透传 + res2 = validate_args(["foo", "bar", "--flag=value"]) + assert res2 == ["foo", "bar", "--flag=value"] + + # 3. 空字符串与空列表 + assert validate_args("") == [] + assert validate_args(" ") == [] + assert validate_args([]) == [] + + # 4. 非法类型 + with pytest.raises(ValueError, match="args must be a string or a list"): + validate_args(123) + with pytest.raises(ValueError, match="args list must contain only strings"): + validate_args(["valid", 123]) + + # 5. 引号语法未闭合 + with pytest.raises(ValueError, match="Invalid args string syntax"): + validate_args("unclosed 'quote") + + # 6. 超过最大参数数量 + with pytest.raises(ValueError, match=f"args must not contain more than {MAX_ARGS_COUNT}"): + validate_args(["arg"] * (MAX_ARGS_COUNT + 1)) + + # 7. 单参数超长 + with pytest.raises(ValueError, match="exceeds maximum length"): + validate_args(["a" * (MAX_ARG_LENGTH + 1)]) + + +def test_config_args_and_quiet_setting(tmp_path, capsys): + from opsscript_gate.cli import main + from opsscript_gate.config import load_config + import json + import pytest + + cfg_file = tmp_path / ".opsscript-gate.json" + cfg_file.write_text(json.dumps({ + "args": ["deploy", "--env=prod"], + "quiet": True, + })) + + cfg = load_config(str(cfg_file)) + assert cfg["args"] == ["deploy", "--env=prod"] + assert cfg["quiet"] is True + + # 测试字符串格式的 args 在 load_config 中被规范化 + cfg_file2 = tmp_path / "config2.json" + cfg_file2.write_text(json.dumps({ + "args": "staging --retry 3", + "quiet": False, + })) + cfg2 = load_config(str(cfg_file2)) + assert cfg2["args"] == ["staging", "--retry", "3"] + assert cfg2["quiet"] is False + + # 测试非法 quiet + cfg_file_bad = tmp_path / "bad.json" + cfg_file_bad.write_text(json.dumps({"quiet": "not_bool"})) + with pytest.raises(ValueError, match="quiet must be a boolean"): + load_config(str(cfg_file_bad)) + + +def test_remediation_dash_and_ash_specific_rules(): + from opsscript_gate.remediation import generate_remediation_hint + + # Alpine 缺少 dash + hint_alpine_dash = generate_remediation_hint( + distro="alpine:3.20", + command="/bin/dash", + kind="missing_command", + ) + assert hint_alpine_dash is not None + assert "busybox ash" in hint_alpine_dash + assert "dash" in hint_alpine_dash + + # Debian 缺少 ash + hint_debian_ash = generate_remediation_hint( + distro="debian:12-slim", + command="/bin/ash", + kind="missing_command", + ) + assert hint_debian_ash is not None + assert "dash as /bin/sh" in hint_debian_ash + assert "ash" in hint_debian_ash + + +def test_build_container_execution_command(): + from opsscript_gate.runner import ( + build_container_execution_command, + SUPPORTED_SHEBANG_COMMANDS, + DEFAULT_POSIX_COMMAND, + ) + + # 1. 默认 posix 命令,无参数 + cmd1 = build_container_execution_command(DEFAULT_POSIX_COMMAND, "/tmp/target_script.sh") + assert cmd1 == ["/bin/sh", "-c", "/bin/sh /tmp/target_script.sh