diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c9683ed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The last TSV field intentionally encodes an empty argv token. Do not trim it. +src/test/resources/opencli-contracts/v1/argv.tsv whitespace=-blank-at-eol diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..58a9d9c --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,135 @@ +name: OpenCLI Contracts + +on: + push: + branches: + - 'feature/1.0.x' + - 'feature/2.0.x' + - 'feature/3.0.x' + - 'feature/*-contract-hardening' + workflow_dispatch: + +permissions: + contents: read + +jobs: + specification: + name: OpenSpec strict and evidence-runner tests + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + OPENSPEC_TELEMETRY: '0' + DO_NOT_TRACK: '1' + CI: 'true' + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Verify report runner and shared fixture hashes + run: | + python3 -m unittest discover -s scripts/tests -v + (cd src/test/resources/opencli-contracts/v1 && sha256sum -c SHA256SUMS) + - name: Install pinned official OpenSpec + run: npm install --prefix "$RUNNER_TEMP/opencli-openspec" --ignore-scripts --no-audit --no-fund @fission-ai/openspec@1.13.1 + - name: Validate all proposals and each change with strict mode + shell: bash + run: | + export PATH="$RUNNER_TEMP/opencli-openspec/node_modules/.bin:$PATH" + mkdir -p spec-evidence + openspec --version | tee spec-evidence/version.txt + openspec list --json > spec-evidence/list.json + failed=0 + for path in openspec/changes/*; do + [[ -d "$path" && "$(basename "$path")" != archive ]] || continue + change="$(basename "$path")" + if openspec validate "$change" --strict --no-interactive > "spec-evidence/$change.log" 2>&1; then + printf '%s\tPASS\n' "$change" + else + cat "spec-evidence/$change.log" + failed=1 + fi + done + if ! openspec validate --all --strict --no-interactive > spec-evidence/all.log 2>&1; then + cat spec-evidence/all.log + failed=1 + fi + exit "$failed" + - name: Upload specification evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: openspec-strict-${{ github.sha }} + path: spec-evidence + if-no-files-found: error + + jvm: + name: Branch-specific JVM contract verification + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - name: Select declared branch line + id: line + shell: bash + run: | + case "$GITHUB_REF_NAME" in + feature/1.0.x*) echo 'java=8' >> "$GITHUB_OUTPUT" ;; + feature/2.0.x*) echo 'java=17' >> "$GITHUB_OUTPUT" ;; + feature/3.0.x*) echo 'java=21' >> "$GITHUB_OUTPUT" ;; + *) echo 'Unsupported branch line' >&2; exit 1 ;; + esac + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ steps.line.outputs.java }} + cache: maven + - name: Record exact source and actual tools + run: | + mkdir -p contract-evidence + git rev-parse HEAD > contract-evidence/head.txt + git archive -o contract-evidence/source.tar HEAD + sha256sum contract-evidence/source.tar > contract-evidence/source.sha256 + java -version > contract-evidence/java.txt 2>&1 + if ! bash ./mvnw -version > contract-evidence/maven.txt 2>&1; then + cat contract-evidence/maven.txt + exit 1 + fi + cat contract-evidence/java.txt contract-evidence/maven.txt + - name: Full clean verify using this branch wrapper + id: verify + shell: bash + run: | + set +e + bash ./mvnw -B --no-transfer-progress clean verify > contract-evidence/maven.log 2>&1 + result=$? + printf '%s\n' "$result" > contract-evidence/maven.exit + tail -100 contract-evidence/maven.log + exit "$result" + - name: Require nonempty, non-skipped contract evidence + if: always() + shell: bash + run: | + test -f contract-evidence/maven.exit + python3 scripts/contract_report.py \ + --reports target/surefire-reports \ + --head "$(cat contract-evidence/head.txt)" \ + --branch "$GITHUB_REF_NAME" \ + --java-version-file contract-evidence/java.txt \ + --maven-version-file contract-evidence/maven.txt \ + --exit-code "$(cat contract-evidence/maven.exit)" \ + --suite argv=io.github.easy4j.opencli.contract.OpenCliArgvContractTest \ + --suite argv=io.github.easy4j.opencli.contract.OpenCliStructuredArgvContractTest \ + --suite process=io.github.easy4j.opencli.contract.OpenCliProcessContractTest \ + --suite process=io.github.easy4j.opencli.contract.OpenCliProcessBoundaryTest \ + --output contract-evidence/report.json + - name: Upload actual JVM evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: contracts-jdk-${{ steps.line.outputs.java }}-${{ github.sha }} + path: | + contract-evidence + target/surefire-reports + target/site/jacoco + if-no-files-found: error diff --git a/.github/workflows/repro-bundle.yml b/.github/workflows/repro-bundle.yml new file mode 100644 index 0000000..7c4d9ed --- /dev/null +++ b/.github/workflows/repro-bundle.yml @@ -0,0 +1,42 @@ +name: Offline contract reproduction bundle +on: + push: + branches: ['feature/2.0.x-contract-hardening'] + paths: ['.github/workflows/repro-bundle.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + bundle: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Verify baseline and resolve the proposed safe YAML parser + run: | + bash ./mvnw -B --no-transfer-progress clean verify + bash ./mvnw -B --no-transfer-progress org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get -Dartifact=org.yaml:snakeyaml:2.6 + - name: Package only public Maven artifacts and distribution + shell: bash + run: | + mkdir -p repro-bundle + git rev-parse HEAD > repro-bundle/source-head.txt + java -version > repro-bundle/java.txt 2>&1 + bash ./mvnw -version > repro-bundle/maven.txt 2>&1 + # Never include settings.xml, toolchains.xml, Git credentials or environment dumps. + tar -czf repro-bundle/maven-repository.tar.gz -C "$HOME/.m2" repository + distribution="$(find "$HOME/.m2/wrapper/dists" -type d -name apache-maven-3.9.16 -print -quit)" + test -n "$distribution" + tar -czf repro-bundle/maven-distribution.tar.gz -C "$(dirname "$distribution")" apache-maven-3.9.16 + (cd repro-bundle && sha256sum *.tar.gz > SHA256SUMS) + - uses: actions/upload-artifact@v7 + with: + name: opencli-offline-repro-${{ github.sha }} + path: repro-bundle + retention-days: 3 + if-no-files-found: error diff --git a/.github/workflows/upstream-contracts.yml b/.github/workflows/upstream-contracts.yml new file mode 100644 index 0000000..6a79916 --- /dev/null +++ b/.github/workflows/upstream-contracts.yml @@ -0,0 +1,54 @@ +name: Pinned upstream descriptive contracts +on: + push: + branches: ['feature/2.0.x-contract-hardening'] + paths: ['.github/workflows/upstream-contracts.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + capture: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Checkout exact upstream source, not an assumed npm publication + uses: actions/checkout@v7 + with: + repository: jackwener/OpenCLI + ref: 8271afc67e8504bda94c147f446ee29775d08274 + path: upstream-src + persist-credentials: false + - name: Build the locked source with dependency lifecycle scripts disabled + shell: bash + run: | + mkdir -p upstream-evidence "$RUNNER_TEMP/opencli-isolated-home" + git -C upstream-src rev-parse HEAD > upstream-evidence/upstream-head.txt + npm view @jackwener/opencli@1.8.8 version gitHead dist --json > upstream-evidence/npm-provenance.json 2> upstream-evidence/npm-provenance.stderr || printf 'UNAVAILABLE_IN_REGISTRY\n' > upstream-evidence/npm-status.txt + (cd upstream-src && npm ci --ignore-scripts --no-audit --no-fund && npm run build) + - name: Capture only bounded version and structured help + shell: bash + run: | + export HOME="$RUNNER_TEMP/opencli-isolated-home" + CLI="$GITHUB_WORKSPACE/upstream-src/dist/src/main.js" + timeout 20 node "$CLI" --version > upstream-evidence/version.txt + timeout 20 node "$CLI" --help -f json > upstream-evidence/root-help.json + for namespace in browser daemon auth skills plugin adapter profile; do + timeout 20 node "$CLI" "$namespace" --help -f json > "upstream-evidence/$namespace-help.json" + done + cp upstream-src/cli-manifest.json upstream-evidence/cli-manifest.json + cp upstream-src/package.json upstream-evidence/package.json + tar -czf upstream-evidence/opencli-source.tar.gz -C upstream-src src cli-manifest.json package.json package-lock.json + node --version > upstream-evidence/node-version.txt + date -u +%FT%TZ > upstream-evidence/captured-at.txt + (cd upstream-evidence && sha256sum *.json *.txt *.tar.gz > SHA256SUMS) + - uses: actions/upload-artifact@v7 + if: always() + with: + name: opencli-source-8271afc-descriptive-${{ github.sha }} + path: upstream-evidence + retention-days: 14 + if-no-files-found: error diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..2cc7d4a Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties old mode 100755 new mode 100644 index 216df05..27e7135 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,3 @@ wrapperVersion=3.3.4 -distributionType=only-script +distributionType=bin distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/docs/implementation/c10-c01-execution.md b/docs/implementation/c10-c01-execution.md new file mode 100644 index 0000000..679e197 --- /dev/null +++ b/docs/implementation/c10-c01-execution.md @@ -0,0 +1,79 @@ +# C10 基础与 C01 / C02 第一批实施记录 + +> 2026-09-21。状态:IMPLEMENTATION_IN_PROGRESS / REVIEW_REQUIRED。 +> 本文记录实际实现和实际验证,不代表十个 Change 已完成,也不关闭三分支集成门禁。 + +## 1. 执行顺序与分支边界 + +顺序保持:C10 共享测试基础 → C01/C02/C03/C04/C05/C09 → C06/C07/C08 → C10 最终集成。 + +标准实现线为 `feature/2.0.x`。本批在隔离分支 `feature/2.0.x-contract-hardening` 实施,基点为 `d0c8056990f7a47fcc202acffa387ba066bcfc67`。1.x 基点 `abba809f11dae68437c39d2ea5a2f4cf8798c0ef`,3.x 基点 `6e38904bdfcae90ec617e8d29bf3d8cf2f002893`。共享 fixture 的 sources.lock.json 保存三线 Java/Jackson/Maven 允许差异。当前实施没有移动这三个原始 feature 分支,也没有修改 main。 + +本批未开始 C03/C04/C05/C06/C07/C08 的新实现;移除本地 Executor/Adapter 默认参数日志只是 C09 的局部改进,不等于整个 SDK 的日志/异常隐私治理完成。 + +## 2. 实际 RED → GREEN + +| 阶段 | 精确提交 | 验证结果 | GitHub Actions run | +|---|---|---|---| +| C01 原始参数 RED | `94de053f4ae7a0912201c2bbeab81b8332f660b8` | 全套 2027 项;21 failures、0 errors、0 skipped。新增 33 项参数契约中 21 项失败 | [35562286431](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562286431) | +| SDK 层修复后的剩余 RED | `c93a25c182f0b0acd15cdfa01089599b52601854` | 2028 项;仍有 11 项失败,原因在 Commons Exec 内部 trim | [35562634249](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562634249) | +| 原始参数 GREEN | `6f830b4f2e5b87bb57eb6b3b669fd299ee48ee39` | clean verify、参数契约门禁、官方 strict、报告上传通过 | [35562782412](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562782412) | +| 有序选项/快照 RED | `d02b16ddc5e0fe7c1d98c6847f4b2999cf2f2566` | 2038 项;10 failures、0 errors、0 skipped;原始 33 项参数契约仍通过 | [35562952517](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562952517) | +| 有序选项/快照 GREEN | `48b14388bc1c86fcf1dff2705f0140a541a3fa75` | 完整验证与官方 strict 通过 | [35563156619](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35563156619) | +| C02 资源行为 RED | `31bde9cc4bda23519b6a81d66df7ddb781d94548` | 2046 项;新增八项全部失败;0 errors、0 skipped | [35563763025](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35563763025) | +| 执行核 GREEN | `98e3db1f8883b8485ec8a428238173dc1148dfb4` | 完整验证、资源回归、官方 strict 通过 | [35564181728](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35564181728) | +| 扩展边界 GREEN | `3bacc8d09b1eeeaa05d6137e2a7e9c350d6622ce` | Surefire XML 实际合计 2052 项,0 failures、0 errors、0 skipped;argv 43 项、process 14 项 | [35564455068](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35564455068) | + +初始 `cdc8b66` 的 CI 因 Wrapper JAR 缺失、隐藏证据目录未被上传而失败;它不是有效行为 RED。修复构建基础后才得到表中的真实失败证据。2.x Wrapper 复用仓库已有的 launcher JAR,保留 Maven 3.9.16 分发;未修改 POM 依赖版本或降低 JaCoCo 门槛。 + +## 3. C10 已建立的基础 + +`src/test/resources/opencli-contracts/v1/` 包含版本化的 UTF-8/base64 参数向量、内容 hash、来源 lock、三线基点和允许差异。向量明确标为 synthetic offline,不伪装成上游或网站采集结果。TSV 最后一列可以为空;限定到该文件的 .gitattributes 保留末尾分隔符,不允许格式化器把空参数删除。 + +`ContractProbe` 输出真实 JVM 参数,覆盖 Executor List/varargs、Adapter List/varargs、结构化 request 与 Browser fill 路径。不是只断言 Recording executor 的前两项。 + +`scripts/contract_report.py` 分开记录 enumeration、argv、process、protocol、typed-result、real-execution。缺报告、零测试、required suite 被跳过、实际 Maven exit 非零、坏 XML、声明数量与实际 testcase 不一致均失败。13 项 Python 检查器测试经过本地 RED→GREEN,并在 CI 再次运行。 + +NOT_RUN 表示本轮没有为该层选择新的契约证据,不表示既有协议/解析测试被跳过;完整 Maven 套件仍运行全部既有测试。Java 子进程探针不等于 live OpenCLI/网站验证。 + +`.github/workflows/contracts.yml` 使用分支对应 JDK 与 checked-in Wrapper,记录精确 HEAD、实际 Java/Maven 版本、退出码、Surefire XML、JaCoCo 和分层报告。所有十个 Change 均使用官方 OpenSpec 1.13.1 逐项及 `--all --strict --no-interactive` 校验;在上述绿色 run 中均通过。 + +## 4. C01 的实现内容和兼容约束 + +参数值原样保留空字符串、空白、换行、Unicode、引号、`--` 和含 `=` 的内容。null 元素按字段和索引报错,不在错误中拼接其它参数值。命令标识符与值分开校验;输入在排队之前做快照。 + +Commons Exec 的 Argument 构造器在 handleQuoting=false 时仍 trim;先由 LiteralCommandLine 保留原始向量,当前执行核再把完整向量直接交给 ProcessBuilder。没有自动 shell 展开或拼接 shell 字符串。 + +OpenCliOptionSchema / OpenCliOption 增量支持带值选项、可重复有序 occurrences、显式 false 和否定 flag,拒绝 schema 冲突与 legacy/ordered 重叠。旧 Map 中 Boolean 仍保持历史 presence-only 行为;不能把旧 false 静默改成否定 flag。Map 和可变对象的常规值在构造时捕获。 + +Windows `.cmd/.bat` 被明确拒绝,调用方应使用原生 node 可执行文件及 JS 路径;Windows/macOS 实际执行尚未验证。legacy appendQuotedKeyValue 保留,不作为新的 literal 路径使用。 + +## 5. C02 的实现内容和边界 + +每个 Executor 默认持有稳定的容量 owner;显式共享 OpenCliProcessRuntime 才跨 Client 共用许可。0 使用 CPU 派生默认值,负数拒绝。旧静态 bridge 仅保留给兼容调用,且在存在提交时拒绝重配;构造新 Client 不再修改它。 + +System.nanoTime 驱动提交到执行的总预算。排队到期不创建子进程;启动前取消也不创建子进程。stdout 默认捕获 8 MiB,stderr 2 MiB;超过预算返回 OUTPUT_LIMIT 失败和有界部分输出。字段分别记录 retained/observed 字节数与截断标记;UTF-8 截断采用替换字符解码,字节计数不根据重新编码后的文本计算。 + +清理只有有限 grace,默认 5 秒;中断标记会恢复,许可在收尾后释放。主进程或读取线程未确认结束时,runtime 被隔离,不继续积累新的不确定资源。结果携带 terminationReason、cleanupState、processStarted、streamsDrained、字节计数和 elapsed/queue 时间。 + +主进程确认退出不等于所有后代退出。当前 portable backend 固定保留 descendantsExitConfirmed=false;不按进程名/PID 批量终止共享 daemon 或用户浏览器。同步 follow 仍受有限 timeout/capture 约束;没有实现无限流式 sink 或远端取消协议。 + +## 6. 尚未满足的关闭条件 + +C01/C02 的 Change 继续 IN_PROGRESS;不能因为上述测试通过就整体勾选或 archive。 + +- C01:自定义 Object.toString() 返回 null 的输入应补专门拒绝测试;Windows/macOS 和三版本线实际完整回归尚未完成。 +- C02:底层 stop/destroy 抛 unchecked 异常时的收尾保护需要故障注入验证和补强;未知清理、取消/自然退出竞争及未确认后代的验收仍需继续。 +- C09:HTTP、WS、诊断和异常 cause 链仍需独立隐私回归;本地参数日志减少不构成全域完成。 +- C03/C04/C05 尚未进入本轮新实现,C06/C07/C08 也尚未进入;不得跳过这些项关闭 C10。 +- 1.x/JDK8、3.x/JDK21、真实 OpenCLI 输出采集、CodeGraph 索引以及独立代码评审未完成。不能用本批 JDK17 证据代替它们。 + +## 7. 实际产物验证 + +扩展边界 run 35564455068 的 JDK artifact SHA-256:`86cc81f83792b5c65d648c04db1afee6970f46335583c7ad8ff2cdb34b6f4897`。 +其中 committed-source tar SHA-256:`c11fe761f44af076f29dffe8aa10f3000c9d1d421bcfc83aa2191da4afe08d35`。 +该 tar 在容器重建的 Git tree 精确等于 `f421af093023fb11683fcd4334806f12d9aa140c`,与 3bacc8d 的远端树一致;这是固定源码快照,不是声称已 clone 完整 Git 历史或同步用户 Mac。 + +官方 strict artifact SHA-256:`aac29fdd4c5c3f7c9c556e7a92036d4956c630609ad5a9f37ddd25dfc1c24a24`;all.log 实际为 10 passed / 0 failed,version.txt 为 1.13.1。 + +后续提交会使 HEAD 改变,最终状态应读取该精确 HEAD 的新 CI,而不是重复使用旧绿色结果。 diff --git a/openspec/implementation-status.json b/openspec/implementation-status.json new file mode 100644 index 0000000..2ecbb45 --- /dev/null +++ b/openspec/implementation-status.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "asOf": "2026-09-21", + "state": "IMPLEMENTATION_IN_PROGRESS", + "canonicalLine": "feature/2.0.x", + "implementationBranch": "feature/2.0.x-contract-hardening", + "verifiedCodeHead": "3bacc8d09b1eeeaa05d6137e2a7e9c350d6622ce", + "verifiedCodeTree": "f421af093023fb11683fcd4334806f12d9aa140c", + "runId": 35564455068, + "javaTests": {"tests": 2052, "failures": 0, "errors": 0, "skipped": 0}, + "selectedContracts": {"argv": 43, "process": 14}, + "pythonRunnerSelfTests": 13, + "officialOpenSpec": {"version": "1.13.1", "strictChangesPassed": 10, "failed": 0}, + "changes": { + "C10": "FOUNDATION_IMPLEMENTED_IN_CANONICAL_BRANCH; FINAL_GATE_OPEN", + "C01": "CORE_IMPLEMENTED; EDGE_CASES_AND_MULTIBRANCH_REVIEW_PENDING", + "C02": "CORE_IMPLEMENTED; CLEANUP_FAULT_INJECTION_AND_MULTIBRANCH_REVIEW_PENDING", + "C03": "NOT_STARTED", + "C04": "NOT_STARTED", + "C05": "NOT_STARTED", + "C06": "NOT_STARTED", + "C07": "NOT_STARTED", + "C08": "NOT_STARTED", + "C09": "LOCAL_LOG_REDUCTION_ONLY; FULL_PRIVACY_CONTRACT_NOT_IMPLEMENTED" + }, + "originalFeatureBranchesChanged": false, + "mainChanged": false, + "localMacSynchronized": false, + "threeBranchIntegrationGate": "OPEN", + "independentCodeReview": "PENDING", + "codeGraph": "NOT_RUN", + "liveOpenCliVerification": "NOT_RUN", + "ledger": "../docs/implementation/c10-c01-execution.md" +} diff --git a/scripts/contract_report.py b/scripts/contract_report.py new file mode 100644 index 0000000..b0c8c78 --- /dev/null +++ b/scripts/contract_report.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Build fail-closed, layer-specific evidence from real Surefire XML reports. + +This is not a code coverage percentage or a claim that OpenCLI websites work. +Only explicitly selected suites contribute to a layer. No network is used. +""" +import argparse +import json +import pathlib +import re +import sys +import xml.etree.ElementTree as ET + +LAYERS = ('enumeration', 'argv', 'process', 'protocol', 'typed-result', 'real-execution') + + +def _suite_summary(suite): + declared = {key: int(suite.attrib.get(key, '0')) for key in ('tests', 'failures', 'errors', 'skipped')} + cases = suite.findall('testcase') + actual = {'tests': len(cases), 'failures': sum(c.find('failure') is not None for c in cases), + 'errors': sum(c.find('error') is not None for c in cases), + 'skipped': sum(c.find('skipped') is not None for c in cases)} + if declared != actual or any(v < 0 for v in declared.values()): + raise ValueError('declared testcase counts disagree with XML evidence') + actual['executed'] = actual['tests'] - actual['skipped'] + return actual + + +def build_report(reports_dir, *, head, branch, java_version, maven_version, exit_code, required_suites): + """Return a JSON-serializable report; malformed/missing evidence fails closed.""" + problems = [] + if not re.fullmatch(r'[0-9a-fA-F]{40}', head or ''): + problems.append('exact 40-character source HEAD is required') + if not branch or not java_version.strip() or not maven_version.strip(): + problems.append('branch and actual tool versions are required') + if exit_code != 0: + problems.append('verification command exited nonzero') + if not required_suites or any(layer not in LAYERS or not names for layer, names in required_suites.items()): + problems.append('non-empty required suites must use recognized layers') + suites = {} + for path in sorted(pathlib.Path(reports_dir).glob('TEST-*.xml')): + try: + root = ET.parse(path).getroot() + nodes = [root] if root.tag == 'testsuite' else list(root.findall('testsuite')) + if not nodes: + raise ValueError('no testsuite') + for suite in nodes: + name = suite.attrib['name'] + if name in suites: + raise ValueError('duplicate testsuite') + suites[name] = _suite_summary(suite) + except (ET.ParseError, OSError, KeyError, ValueError): + problems.append('invalid or duplicate Surefire report: ' + path.name) + layers = {} + for layer in LAYERS: + names = list(required_suites.get(layer, [])) + counts = dict.fromkeys(('tests', 'executed', 'skipped', 'failures', 'errors'), 0) + missing = [name for name in names if name not in suites] + for name in names: + for key in counts: + counts[key] += suites.get(name, {}).get(key, 0) + state = 'NOT_RUN' + if names: + state = 'PASS' if (not missing and counts['executed'] > 0 and + not any(counts[key] for key in ('skipped', 'failures', 'errors'))) else 'FAIL' + layers[layer] = dict(counts, status=state, requiredSuites=names, missingSuites=missing) + if state == 'FAIL': + problems.append('required layer lacks passing, non-skipped execution: ' + layer) + return {'schemaVersion': 1, 'status': 'FAIL' if problems else 'PASS', + 'head': head, 'branch': branch, 'javaVersion': java_version, + 'mavenVersion': maven_version, 'commandExitCode': exit_code, + 'layers': layers, 'problems': problems, + 'scope': 'Explicit Surefire suites only; synthetic argv/process probes are not live OpenCLI verification.'} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reports', type=pathlib.Path, required=True) + parser.add_argument('--head', required=True) + parser.add_argument('--branch', required=True) + parser.add_argument('--java-version-file', type=pathlib.Path, required=True) + parser.add_argument('--maven-version-file', type=pathlib.Path, required=True) + parser.add_argument('--exit-code', type=int, required=True) + parser.add_argument('--suite', action='append', required=True, help='layer=fully.qualified.TestClass; repeatable') + parser.add_argument('--output', type=pathlib.Path, required=True) + args = parser.parse_args(argv) + required = {} + for item in args.suite: + layer, separator, name = item.partition('=') + if not separator or layer not in LAYERS or not name: + parser.error('--suite must be recognized-layer=fully.qualified.TestClass') + if name in required.setdefault(layer, []): + parser.error('duplicate --suite') + required[layer].append(name) + try: + report = build_report(args.reports, head=args.head, branch=args.branch, + java_version=args.java_version_file.read_text(encoding='utf-8'), + maven_version=args.maven_version_file.read_text(encoding='utf-8'), + exit_code=args.exit_code, required_suites=required) + except OSError as exc: + print('Missing tool-version evidence: ' + exc.__class__.__name__, file=sys.stderr) + return 1 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + print(json.dumps({'status': report['status'], 'head': report['head'], 'layers': report['layers']})) + return 0 if report['status'] == 'PASS' else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/tests/test_contract_report.py b/scripts/tests/test_contract_report.py new file mode 100644 index 0000000..b9bfd2e --- /dev/null +++ b/scripts/tests/test_contract_report.py @@ -0,0 +1,105 @@ +import importlib.util +import json +import pathlib +import tempfile +import unittest + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'contract_report.py' +HEAD = 'd0c8056990f7a47fcc202acffa387ba066bcfc67' +SUITE = 'io.github.easy4j.opencli.contract.OpenCliArgvContractTest' + + +class ContractReportTest(unittest.TestCase): + def setUp(self): + self.assertTrue(SCRIPT.is_file(), 'C10 contract report runner is not implemented') + spec = importlib.util.spec_from_file_location('contract_report', SCRIPT) + self.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.module) + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = pathlib.Path(self.temp.name) + + def xml(self, tests=2, skipped=0, failures=0, errors=0): + executed = tests - skipped + cases = [] + for i in range(tests): + child = '' if i >= executed else ('' if i < failures else ('' if i < failures + errors else '')) + cases.append('' + child + '') + (self.root / ('TEST-' + SUITE + '.xml')).write_text( + '' + ''.join(cases) + '', encoding='utf-8') + + def report(self, **kwargs): + return self.module.build_report(self.root, head=kwargs.get('head', HEAD), + branch='feature/2.0.x', java_version='openjdk 17 (test fixture)', + maven_version='Apache Maven (test fixture)', exit_code=kwargs.get('exit_code', 0), + required_suites={'argv': [SUITE]}) + + def test_complete_argv_evidence_passes_but_live_is_not_run(self): + self.xml() + report = self.report() + self.assertEqual('PASS', report['status']) + self.assertEqual(2, report['layers']['argv']['executed']) + self.assertEqual('NOT_RUN', report['layers']['real-execution']['status']) + self.assertEqual(HEAD, report['head']) + json.dumps(report) + + def test_process_layer_is_separate_from_real_execution(self): + self.xml() + report = self.module.build_report(self.root, head=HEAD, branch='feature/2.0.x', + java_version='fixture-jdk', maven_version='fixture-maven', exit_code=0, + required_suites={'process': [SUITE]}) + self.assertEqual('PASS', report['status']) + self.assertEqual(2, report['layers']['process']['executed']) + self.assertEqual('NOT_RUN', report['layers']['real-execution']['status']) + + def test_missing_report_fails(self): + self.assertEqual('FAIL', self.report()['status']) + + def test_zero_tests_fails(self): + self.xml(tests=0) + self.assertEqual('FAIL', self.report()['status']) + + def test_all_skipped_fails(self): + self.xml(tests=2, skipped=2) + self.assertEqual('FAIL', self.report()['status']) + + def test_partly_skipped_fails(self): + self.xml(tests=2, skipped=1) + self.assertEqual('FAIL', self.report()['status']) + + def test_failure_fails(self): + self.xml(failures=1) + self.assertEqual('FAIL', self.report()['status']) + + def test_error_fails(self): + self.xml(errors=1) + self.assertEqual('FAIL', self.report()['status']) + + def test_nonzero_command_exit_fails_despite_green_xml(self): + self.xml() + self.assertEqual('FAIL', self.report(exit_code=1)['status']) + + def test_malformed_xml_fails_closed(self): + (self.root / 'TEST-broken.xml').write_text('', '', 1), encoding='utf-8') + self.assertEqual('FAIL', self.report()['status']) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java b/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java index 6c87513..e60a87a 100644 --- a/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java +++ b/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java @@ -7,112 +7,46 @@ import lombok.Data; /** - * OpenCLI runtime configuration POJO with no Spring dependency. - *

- * 描述可执行文件、工作目录、超时、全局 argv 前缀、远端 Agent 以及需要注入子进程的环境变量 - *(例如 {@code OPENCLI_CDP_ENDPOINT})。Spring Boot 可由上层以 - * {@code @ConfigurationProperties(prefix = "opencli")} 绑定同名字段。 - *

- *

- * {@link #commandTimeoutMillis} 在本地模式下用于子进程 Watchdog;在 - * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 模式下用作 HTTP 客户端超时上限。 - *

- */ -@Data/** - - * OpenCLI runtime configuration POJO with no Spring dependency. - * - *

Describes the executable path, working directory, timeout, global argv prefix, - * remote Agent settings, and environment variables injected into the subprocess - * (e.g. {@code OPENCLI_CDP_ENDPOINT}). Spring Boot applications can bind these - * fields via {@code @ConfigurationProperties(prefix = "opencli")}.

+ * OpenCLI runtime configuration without Spring dependencies. + * Local execution uses a monotonic submission-to-exit deadline; cleanup has a + * separate finite grace. Remote HTTP retains its own transport timeout semantics. * - *

{@link #commandTimeoutMillis} is used as the subprocess watchdog timeout in local - * mode and as the HTTP client timeout in {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} mode.

- - * - * @author Loong Wan - * @since 3.0.0 - */ - +@Data public class OpenCliProperties { - - /** - * 执行目标:本机进程或与 opencli-admin 兼容的远端 Agent。 - */ private OpenCliExecutionTarget executionTarget = OpenCliExecutionTarget.LOCAL_PROCESS; - - /** - * 远端 Agent 根 URL(不含尾斜杠),例如 {@code http://192.168.1.10:19823}。 - *

仅当 {@link #executionTarget} 为 {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 时必填。

- */ + /** Root URL of an opencli-admin compatible Agent. */ private String remoteAgentBaseUrl; - - /** - * 传给 Agent collect 的 {@code mode}:{@code bridge} 或 {@code cdp}(与 Agent 环境一致)。 - */ private String remoteCollectMode = "cdp"; - - /** - * 传给 Agent collect 的默认 {@code format}(可被 argv 中的 {@code -f} 覆盖)。 - */ private String remoteOutputFormat = "json"; - - /** - * 对应 collect body 的 {@code cdp_endpoint};空表示由 Agent 使用自身 {@code OPENCLI_CDP_ENDPOINT}。 - */ private String remoteCdpEndpoint = ""; - - /** - * 为 true 时,远程模式下将 Agent HTTP 响应原文写入 {@link io.github.easy4j.opencli.core.OpenCliResult} 的 - * {@code remoteRawHttpBody} 字段; - * 本地模式无效果。大响应时请谨慎开启。 - */ + /** Raw HTTP capture is explicitly opt-in and may contain sensitive business data. */ private boolean remoteCaptureRawHttpResponse = false; - - /** - * OpenCLI 可执行文件名或绝对路径;默认假定已在 {@code PATH} 中。 - */ private String executable = "opencli"; - - /** - * 子进程工作目录;为空时使用 JVM 当前目录。 - */ private String workingDirectory; - - /** - * 单次调用超时(毫秒):本地模式用于子进程 Watchdog;远程模式用于 Agent HTTP 请求。 - */ + /** Total local queue plus execution budget; remote mode uses an HTTP timeout. */ private long commandTimeoutMillis = 300_000L; - - /** - * 本机 CLI 子进程最大并发数;小于等于 0 时使用 CPU 核心数与 2 的较大值。 - */ + /** Positive per-client capacity, zero for max(2, cores); negative is invalid. Captured at construction. */ private int maxConcurrentExecutions = 0; - - /** - * 启动探测({@code opencli list})专用超时(毫秒);小于等于 0 时探测使用 30 秒。 - */ + /** Maximum bytes retained from stdout for one local invocation. */ + private int maxStdoutBytes = 8 * 1024 * 1024; + /** Maximum bytes retained from stderr for one local invocation. */ + private int maxStderrBytes = 2 * 1024 * 1024; + /** Independent bounded cleanup grace, in milliseconds. */ + private long cleanupGraceMillis = 5_000L; private long startupProbeTimeoutMillis = 30_000L; - - /** - * 附加到 {@code opencli} 之后的最前参数(在 adapter 名之前),便于预留 profile 等扩展。 - */ + /** Literal prefix arguments placed before the command identifier. */ private List leadingArguments = new ArrayList<>(); - - /** - * 合并进子进程环境的键值;覆盖同名系统环境变量。 - */ + /** Variables overlaying the inherited process environment. */ private Map environment = new LinkedHashMap<>(); /** - * 复制为「仅本机子进程」配置,供边缘 WebSocket Agent 处理中心下发的 {@code collect} 时使用, - * 避免误将 collect 再次转发为 {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 而形成回路。 + * Copy into a local-only configuration for reverse-Agent execution without + * forwarding a received command back to a remote Agent. * - * @return 新实例,不会改变当前对象 + * @return an independent local configuration */ public OpenCliProperties copyForLocalCliExecution() { OpenCliProperties c = new OpenCliProperties(); @@ -120,6 +54,9 @@ public OpenCliProperties copyForLocalCliExecution() { c.setWorkingDirectory(this.workingDirectory); c.setCommandTimeoutMillis(this.commandTimeoutMillis); c.setMaxConcurrentExecutions(this.maxConcurrentExecutions); + c.setMaxStdoutBytes(this.maxStdoutBytes); + c.setMaxStderrBytes(this.maxStderrBytes); + c.setCleanupGraceMillis(this.cleanupGraceMillis); c.setStartupProbeTimeoutMillis(this.startupProbeTimeoutMillis); c.setLeadingArguments(new ArrayList<>(this.leadingArguments)); c.setEnvironment(new LinkedHashMap<>(this.environment)); diff --git a/src/main/java/io/github/easy4j/opencli/core/LiteralCommandLine.java b/src/main/java/io/github/easy4j/opencli/core/LiteralCommandLine.java new file mode 100644 index 0000000..0ed24e4 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/LiteralCommandLine.java @@ -0,0 +1,46 @@ +package io.github.easy4j.opencli.core; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.apache.commons.exec.CommandLine; + +/** + * Internal literal argv bridge. Commons Exec's Argument constructor trims even + * when handleQuoting is false, so raw values cannot be stored in its argument list. + * Keep the original vector and supply it directly to the native launcher instead. + * + * @author Loong Wan + * @since 3.0.0 + */ +final class LiteralCommandLine extends CommandLine { + private final List literalArguments = new ArrayList<>(); + + LiteralCommandLine(String executable) { + super(executable); + } + + @Override + public CommandLine addArgument(String argument, boolean handleQuoting) { + if (handleQuoting) { + throw new IllegalArgumentException("Literal argv does not support implicit quoting"); + } + literalArguments.add(Objects.requireNonNull(argument, "argument")); + return this; + } + + @Override + public String[] getArguments() { + return literalArguments.toArray(new String[0]); + } + + @Override + public String[] toStrings() { + String[] result = new String[literalArguments.size() + 1]; + result[0] = getExecutable(); + for (int i = 0; i < literalArguments.size(); i++) { + result[i + 1] = literalArguments.get(i); + } + return result; + } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java index 8d2bbe1..062d73f 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java @@ -2,34 +2,27 @@ import io.github.easy4j.opencli.util.OpenCliStrings; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; import lombok.extern.slf4j.Slf4j; /** - * 针对单个 OpenCLI adapter 的轻量通道:自动在 argv 前插入 adapter id。 - */ -@Slf4j/** - - * Lightweight channel for a single OpenCLI adapter: automatically prepends the adapter id - * to every argv invocation. - + * Lightweight channel for one OpenCLI adapter. Values are literal argv tokens; + * only the separately supplied adapter identifier is normalized. * - * @author Loong Wan - * @since 3.0.0 - */ - +@Slf4j public final class OpenCliAdapterChannel { private final OpenCliExecutor executor; private final String adapterId; /** - * @param executor 共享执行器,不得为 null - * @param adapterId 文档中的 adapter 名(如 {@code twitter}),不得为空白 + * @param executor shared executor + * @param adapterId nonblank adapter identifier */ public OpenCliAdapterChannel(OpenCliExecutor executor, String adapterId) { this.executor = Objects.requireNonNull(executor, "executor"); @@ -39,37 +32,31 @@ public OpenCliAdapterChannel(OpenCliExecutor executor, String adapterId) { } } - /** - * @return 当前通道绑定的 adapter id - */ + /** @return this channel's adapter identifier */ public String getAdapterId() { return adapterId; } /** - * 调用 {@code opencli }。 + * Invoke an adapter with a snapshot of the supplied literal arguments. + * An empty list invokes its root; null elements are rejected. * - * @param subcommandAndArgs 子命令及后续参数;不得为 null,可为空(仅调 adapter 根命令时) - * @return 成功时的 {@link OpenCliResult} + * @param subcommandAndArgs subcommand and subsequent values + * @return execution result */ public OpenCliResult invoke(List subcommandAndArgs) { - Objects.requireNonNull(subcommandAndArgs, "subcommandAndArgs"); - List tokens = new ArrayList<>(); + List rest = OpenCliArgSupport.snapshotValues(subcommandAndArgs, "subcommandAndArgs"); + List tokens = new ArrayList<>(rest.size() + 1); tokens.add(adapterId); - for (String s : subcommandAndArgs) { - if (OpenCliStrings.isNotBlank(s)) { - tokens.add(s.trim()); - } - } - log.debug("OpenCLI adapter invoke adapterId={} subcommandSummary={}", adapterId, summarizeSubcommand(tokens)); + tokens.addAll(rest); + // Do not put prompt text or positional values in default diagnostic logs. + log.debug("OpenCLI adapter invoke argvSize={}", tokens.size()); return executor.invoke(tokens); } /** - * 通过 {@link OpenCliAdapterCommandRequest} 发起调用(推荐测试与 SDK 侧结构化入口)。 - * - * @param request 结构化子命令请求,不得为 null - * @return 执行结果 + * @param request structured command request + * @return execution result */ public OpenCliResult invoke(OpenCliAdapterCommandRequest request) { Objects.requireNonNull(request, "request"); @@ -77,38 +64,11 @@ public OpenCliResult invoke(OpenCliAdapterCommandRequest request) { } /** - * {@link #invoke(List)} 的可变参数形式。 - * - * @param subcommandAndArgs 子命令及 flag - * @return 执行结果 + * @param subcommandAndArgs subcommand and literal values + * @return execution result */ public OpenCliResult invoke(String... subcommandAndArgs) { - List list = new ArrayList<>(); - if (Objects.nonNull(subcommandAndArgs)) { - for (String s : subcommandAndArgs) { - if (OpenCliStrings.isNotBlank(s)) { - list.add(s.trim()); - } - } - } - return invoke(list); - } - - private static String summarizeSubcommand(List tokens) { - if (tokens.size() <= 1) { - return "(root)"; - } - int limit = Math.min(tokens.size(), 4); - StringBuilder sb = new StringBuilder(); - for (int i = 1; i < limit; i++) { - if (i > 1) { - sb.append(' '); - } - sb.append(tokens.get(i)); - } - if (tokens.size() > limit) { - sb.append(" ..."); - } - return sb.toString(); + Objects.requireNonNull(subcommandAndArgs, "subcommandAndArgs"); + return invoke(Arrays.asList(subcommandAndArgs)); } } diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java index eea957d..91e1dd2 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java @@ -1,45 +1,33 @@ package io.github.easy4j.opencli.core; import io.github.easy4j.opencli.util.OpenCliStrings; +import java.math.BigDecimal; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.Singular; /** - * 结构化 adapter 子命令请求:由子命令名、positional 参数与命名 options 构建 argv, - * 供 {@link OpenCliAdapterChannel#invoke(OpenCliAdapterCommandRequest)} 及覆盖测试使用。 - *

- * 禁止在测试中手工拼接 {@code List.of("sub", "--flag", "value")};应通过 builder 建模参数。 - *

- */ -@Getter -@Builder/** - - * Structured adapter subcommand request: builds argv from a subcommand name, - * positional arguments, and named options for use with - * {@link OpenCliAdapterChannel#invoke(OpenCliAdapterCommandRequest)}. + * Structured adapter request with immutable literal values and ordered options. + * The legacy options Map retains Boolean presence-only semantics; use + * {@link OpenCliOption} for repeated values, valued false and explicit negation. * - *

Avoid manually assembling {@code List.of("sub", "--flag", "value")} in tests; - * use the builder to model parameters instead.

- - * - * @author Loong Wan - * @since 3.0.0 - */ - +@Getter +@Builder public final class OpenCliAdapterCommandRequest { - - /** 子命令名(不含 adapter id)。 */ private final String subcommand; @Getter(AccessLevel.NONE) @@ -47,87 +35,136 @@ public final class OpenCliAdapterCommandRequest { private final List positionals; @Getter(AccessLevel.NONE) - @Builder.Default - private final Map options = Collections.emptyMap(); + private final Map options; + + @Getter(AccessLevel.NONE) + @Singular("option") + private final List orderedOptions; /** - * @return positional 参数副本 + * Builder customisation snapshots legacy option values before they can be + * changed through the caller's Map or a mutable value object. */ - public List getPositionals() { - if (Objects.isNull(positionals)) { - return Collections.emptyList(); + public static class OpenCliAdapterCommandRequestBuilder { + private Map options; + + /** @param source legacy single-value options @return this builder */ + public OpenCliAdapterCommandRequestBuilder options(Map source) { + options = snapshotOptions(source); + return this; } - return Collections.unmodifiableList(new ArrayList<>(positionals)); } - /** - * @return 命名选项副本 - */ + /** @return an immutable copy of positional values */ + public List getPositionals() { + return positionals == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(positionals)); + } + + /** @return an immutable snapshot of legacy options */ public Map getOptions() { - if (Objects.isNull(options)) { - return Collections.emptyMap(); - } - return Collections.unmodifiableMap(new LinkedHashMap<>(options)); + return options == null ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(options)); } - /** - * 将本请求转换为 {@link OpenCliAdapterChannel#invoke(List)} 所需的 token 列表。 - * - * @return 以 subcommand 开头、随后 positional、再 options 的 argv 片段 - */ + /** @return immutable, ordered option occurrences */ + public List getOrderedOptions() { + return orderedOptions == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(orderedOptions)); + } + + /** @return the validated subcommand and complete literal argv */ public List toSubcommandAndArgs() { Objects.requireNonNull(subcommand, "subcommand"); + if (OpenCliStrings.isBlank(subcommand)) { + throw new IllegalArgumentException("subcommand must not be blank"); + } List tokens = new ArrayList<>(); tokens.add(subcommand.trim()); - if (Objects.nonNull(positionals)) { - for (String p : positionals) { - if (OpenCliStrings.isNotBlank(p)) { - tokens.add(p.trim()); + if (positionals != null) { + tokens.addAll(OpenCliArgSupport.snapshotValues(positionals, "positionals")); + } + Set legacyFlags = new HashSet<>(); + if (options != null) { + for (Map.Entry entry : options.entrySet()) { + String flag = appendLegacyOption(tokens, entry.getKey(), entry.getValue()); + if (flag != null && !legacyFlags.add(flag)) { + throw new IllegalArgumentException("Ambiguous duplicate legacy option identifier"); } } } - if (Objects.nonNull(options)) { - for (Map.Entry entry : options.entrySet()) { - appendOption(tokens, entry.getKey(), entry.getValue()); + Map seen = new HashMap<>(); + Map wireNames = new HashMap<>(); + if (orderedOptions != null) { + for (int i = 0; i < orderedOptions.size(); i++) { + OpenCliOption occurrence = orderedOptions.get(i); + if (occurrence == null) { + throw new IllegalArgumentException("orderedOptions[" + i + "] must not be null"); + } + OpenCliOptionSchema schema = occurrence.getSchema(); + String name = schema.getName(); + List argv = occurrence.toTokens(); + String wireName = argv.get(0); + if (legacyFlags.contains(name) || legacyFlags.contains(wireName)) { + throw new IllegalArgumentException("Legacy and ordered options must not overlap"); + } + OpenCliOptionSchema previous = seen.put(name, schema); + if (previous != null && (!schema.isRepeatable() || !previous.equals(schema))) { + throw new IllegalArgumentException("Repeated option is not allowed by one consistent schema"); + } + String previousCanonical = wireNames.put(wireName, name); + if (previousCanonical != null && !previousCanonical.equals(name)) { + throw new IllegalArgumentException("Different schemas emit the same option identifier"); + } + tokens.addAll(argv); } } return tokens; } - private static void appendOption(List target, String name, Object value) { - if (OpenCliStrings.isBlank(name) || Objects.isNull(value)) { - return; - } - String flag = name.startsWith("-") ? name.trim() : "--" + name.trim(); - if (value instanceof Boolean) { - if (((Boolean) value).booleanValue()) { - target.add(flag); + private static Map snapshotOptions(Map source) { + if (source == null) { return Collections.emptyMap(); } + Map snapshot = new LinkedHashMap<>(); + for (Map.Entry entry : new LinkedHashMap<>(source).entrySet()) { + Object value = entry.getValue(); + if (value != null) { + Class type = value.getClass(); + if (type != String.class && type != Boolean.class && type != Byte.class + && type != Short.class && type != Integer.class && type != Long.class + && type != Float.class && type != Double.class && type != BigInteger.class + && type != BigDecimal.class) { + value = String.valueOf(value); + } } - return; + snapshot.put(entry.getKey(), value); + } + return Collections.unmodifiableMap(snapshot); + } + + private static String appendLegacyOption(List target, String name, Object value) { + if (OpenCliStrings.isBlank(name) || value == null || Boolean.FALSE.equals(value)) { + return null; } + String normalized = name.trim(); + String flag = normalized.startsWith("-") ? normalized : "--" + normalized; target.add(flag); - target.add(String.valueOf(value).trim()); + if (!(value instanceof Boolean)) { + target.add(String.valueOf(value)); + } + return flag; } /** - * 从 manifest 风格的 options map 创建请求(测试资源反序列化辅助)。 - * - * @param subcommand 子命令 - * @param positionals positional 列表,可为 null - * @param options 选项 map,可为 null - * @return 请求实例 + * @param subcommand command identifier + * @param positionals optional positional values + * @param options optional legacy single-value options + * @return a structured request */ public static OpenCliAdapterCommandRequest of( - String subcommand, - List positionals, - Map options) { + String subcommand, List positionals, Map options) { OpenCliAdapterCommandRequestBuilder b = builder().subcommand(subcommand); - if (Objects.nonNull(positionals)) { - b.positionals(positionals); - } - if (Objects.nonNull(options) && !options.isEmpty()) { - b.options(new LinkedHashMap<>(options)); - } + if (positionals != null) { b.positionals(positionals); } + if (options != null) { b.options(options); } return b.build(); } } diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java index 34c3620..dc5cc9f 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java @@ -1,55 +1,61 @@ package io.github.easy4j.opencli.core; -import io.github.easy4j.opencli.util.OpenCliStrings; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; /** * CLI argument assembly utilities: merges business segments with pass-through - * {@code additionalRawArgs}. + * {@code additionalRawArgs} without changing argument values. * * @author Loong Wan * @since 3.0.0 - */public final class OpenCliArgSupport { + */ +public final class OpenCliArgSupport { private OpenCliArgSupport() { } /** - * 将前缀片段与可选附加片段合并为连续 argv(过滤 null/空白)。 + * Capture values before execution or queuing. Validate indices without exposing other values. + */ + static List snapshotValues(List values, String field) { + Objects.requireNonNull(values, field); + List copy = new ArrayList<>(values); + for (int i = 0; i < copy.size(); i++) { + if (copy.get(i) == null) { + throw new IllegalArgumentException(field + "[" + i + "] must not be null"); + } + } + return Collections.unmodifiableList(copy); + } + + /** + * Merge optional segments into a new argv list. Null segments are absent; + * null elements are invalid, while empty and whitespace-only values are preserved. * - * @param prefix 子命令与已建模参数,可为 null - * @param additionalRawArgs 额外原生参数,可为 null - * @return 新列表副本 + * @param prefix command and modeled arguments, or null + * @param additionalRawArgs extra literal arguments, or null + * @return a new list, without modifying either source */ public static List merge(List prefix, List additionalRawArgs) { List out = new ArrayList<>(); - if (Objects.nonNull(prefix)) { - for (String s : prefix) { - if (OpenCliStrings.isNotBlank(s)) { - out.add(s.trim()); - } - } + if (prefix != null) { + out.addAll(snapshotValues(prefix, "prefix")); } - if (Objects.nonNull(additionalRawArgs)) { - for (String s : additionalRawArgs) { - if (OpenCliStrings.isNotBlank(s)) { - out.add(s.trim()); - } - } + if (additionalRawArgs != null) { + out.addAll(snapshotValues(additionalRawArgs, "additionalRawArgs")); } return out; } /** - * 追加 {@code --name=value}(value 含空格时由调用方决定是否使用 - * {@link OpenCliExecutor#appendQuotedKeyValue(CommandLine, String, String)}; - * 此处仅做简单拼接)。 + * Append one literal {@code --name=value} token. * - * @param target 目标列表,不得为 null - * @param name 完整名称(含 {@code --},不含 {@code =}) - * @param value 非空值 + * @param target destination list + * @param name full option name, including {@code --} + * @param value non-null value, which may be empty */ public static void addOptionEquals(List target, String name, String value) { Objects.requireNonNull(target, "target"); @@ -63,11 +69,11 @@ public static void addOptionEquals(List target, String name, String valu } /** - * 追加 {@code --flag value} 双 token 形式。 + * Append a {@code --flag value} pair without altering the value. * - * @param target 目标列表 - * @param flag 如 {@code --limit} - * @param value 非空值 + * @param target destination list + * @param flag option name + * @param value non-null value, which may be empty */ public static void addOptionPair(List target, String flag, String value) { Objects.requireNonNull(target, "target"); @@ -78,11 +84,11 @@ public static void addOptionPair(List target, String flag, String value) } /** - * 当 {@code value} 非 null 时追加 {@code --flag value}。 + * Append a pair when the optional value is non-null. * - * @param target 目标 argv 列表 - * @param flag 选项名 - * @param value 可为 null + * @param target destination list + * @param flag option name + * @param value optional value */ public static void addOptionPairIfPresent(List target, String flag, Object value) { if (Objects.nonNull(value)) { @@ -91,11 +97,11 @@ public static void addOptionPairIfPresent(List target, String flag, Obje } /** - * 当 {@code enabled} 为 {@code true} 时追加 boolean flag(无值)。 + * Append a presence-only flag when enabled. This helper is not a valued boolean option. * - * @param target 目标 argv 列表 - * @param flag 如 {@code --follow} - * @param enabled 开关,null/false 时不追加 + * @param target destination list + * @param flag option name + * @param enabled true to append; null/false to omit */ public static void addFlagIfTrue(List target, String flag, Boolean enabled) { if (Boolean.TRUE.equals(enabled)) { diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java new file mode 100644 index 0000000..c90d2db --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java @@ -0,0 +1,14 @@ +package io.github.easy4j.opencli.core; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Cooperative cancellation for one local invocation; cancellation never affects another invocation. */ +public final class OpenCliCancellationToken { + private final AtomicBoolean cancelled = new AtomicBoolean(); + + /** Request cancellation. This operation is idempotent. */ + public void cancel() { cancelled.set(true); } + + /** @return whether cancellation has been requested */ + public boolean isCancelled() { return cancelled.get(); } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java new file mode 100644 index 0000000..98530e3 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java @@ -0,0 +1,33 @@ +package io.github.easy4j.opencli.core; + +import lombok.Builder; +import lombok.Getter; + +/** Immutable execution evidence. Observed byte counts describe bytes read, not bytes produced remotely. */ +@Getter +@Builder +public final class OpenCliExecutionDetails { + /** First terminal condition selected by the invocation owner. */ + public enum TerminationReason { + PROCESS_EXIT, QUEUE_TIMEOUT, EXECUTION_TIMEOUT, OUTPUT_LIMIT, + CANCELLED, SPAWN_FAILED, IO_FAILURE, CLEANUP_UNCONFIRMED, RUNTIME_UNAVAILABLE + } + + /** Confirmation concerns the directly owned child, not an arbitrary process tree. */ + public enum CleanupState { NOT_STARTED, ROOT_EXIT_CONFIRMED, UNCONFIRMED } + + private final TerminationReason terminationReason; + private final CleanupState cleanupState; + private final boolean processStarted; + private final boolean streamsDrained; + private final long stdoutCapturedBytes; + private final long stdoutObservedBytes; + private final boolean stdoutTruncated; + private final long stderrCapturedBytes; + private final long stderrObservedBytes; + private final boolean stderrTruncated; + private final long elapsedMillis; + private final long queueWaitMillis; + /** This portable backend does not claim ownership/termination of detached daemon descendants. */ + private final boolean descendantsExitConfirmed; +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java index f313578..ebb35af 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java @@ -2,114 +2,101 @@ import io.github.easy4j.opencli.OpenCliExecutionTarget; import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason; +import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport; import io.github.easy4j.opencli.exception.OpenCliException; import io.github.easy4j.opencli.exception.OpenCliExecutableFailureException; import io.github.easy4j.opencli.exception.OpenCliNonZeroExitException; import io.github.easy4j.opencli.exception.OpenCliTimeoutException; -import io.github.easy4j.opencli.parser.OpenCliParsedFields; import io.github.easy4j.opencli.remote.OpenCliArgvToCollectParser; import io.github.easy4j.opencli.remote.OpenCliCollectRequest; import io.github.easy4j.opencli.remote.OpenCliRemoteAgentHttpClient; -import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport; import io.github.easy4j.opencli.util.OpenCliStrings; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import lombok.Getter; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.exec.CommandLine; -import org.apache.commons.exec.DefaultExecuteResultHandler; -import org.apache.commons.exec.ExecuteException; -import org.apache.commons.exec.ExecuteWatchdog; /** - * 基于 Apache Commons Exec 的 OpenCLI 子进程执行封装。 - *

- * {@link #invoke(List)} 接受的参数为「紧跟可执行名之后」的完整 token 列表,形如 - * {@code [adapter, subcommand, ...]};本地模式下会自动拼接 {@link OpenCliProperties} 的 - * {@code leadingArguments}。 - *

- *

- * 当 {@link OpenCliProperties} 的 {@code executionTarget} 为 - * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 时, - * 通过 {@link OpenCliRemoteAgentHttpClient} 调用远端 {@code POST /collect};此时 {@code leadingArguments} 不参与请求, - * argv 会被解析为 {@link OpenCliCollectRequest}(与 opencli-admin {@code agent_server} 契约一致)。 - *

- */ -@Slf4j -@Getter/** - - * OpenCLI subprocess execution wrapper based on Apache Commons Exec. - * - *

{@link #invoke(List)} accepts a token list that follows the executable name, - * typically {@code [adapter, subcommand, ...]}. In local mode, {@link OpenCliProperties} - * {@code leadingArguments} are automatically prepended.

+ * Literal-argv executor with a stable capacity owner, total local deadline and + * bounded output. Existing synchronous entry points are retained. Raw HTTP + * collect remains a separate legacy protocol, not a lossless process transport. * - *

When {@link OpenCliProperties#getExecutionTarget()} is - * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP}, the invocation is forwarded to a - * remote Agent via {@link OpenCliRemoteAgentHttpClient#collect(OpenCliCollectRequest)}.

- - * - * @author Loong Wan - * @since 3.0.0 - */ - +@Getter public class OpenCliExecutor { - private final OpenCliProperties properties; - - /** - * 懒加载,仅远程模式使用。 - */ + private final OpenCliProcessRuntime processRuntime; private volatile OpenCliRemoteAgentHttpClient remoteAgentHttpClient; + /** @param properties configuration; process capacity is captured once */ + public OpenCliExecutor(OpenCliProperties properties) { + this(properties, new OpenCliProcessRuntime(Objects.requireNonNull(properties, "properties") + .getMaxConcurrentExecutions())); + } + /** - * @param properties 运行时配置,不得为 null + * @param properties configuration + * @param processRuntime an explicitly shared, stable local capacity owner */ - public OpenCliExecutor(OpenCliProperties properties) { + public OpenCliExecutor(OpenCliProperties properties, OpenCliProcessRuntime processRuntime) { this.properties = Objects.requireNonNull(properties, "properties"); - SubprocessExecutionSupport.configureMaxConcurrentExecutions(properties.getMaxConcurrentExecutions()); + this.processRuntime = Objects.requireNonNull(processRuntime, "processRuntime"); + } + + /** @param adapterAndRest command and literal values @return execution result */ + public OpenCliResult invoke(List adapterAndRest) { + return invokeInternal(adapterAndRest, new OpenCliCancellationToken(), false); } /** - * 执行 {@code opencli ...} 完整 argv(不含可执行文件本身)。 + * Cancellable local invocation. The legacy HTTP protocol does not claim a + * cancellable remote process; explicit tokens are rejected in remote mode. * - * @param adapterAndRest 至少包含 adapter 名,后续为子命令与 flag;不得为 null - * @return 包含成功标记的执行结果 + * @param adapterAndRest command and literal values + * @param cancellationToken cancellation for this invocation only + * @return execution result */ - public OpenCliResult invoke(List adapterAndRest) { - Objects.requireNonNull(adapterAndRest, "adapterAndRest"); + public OpenCliResult invoke(List adapterAndRest, OpenCliCancellationToken cancellationToken) { + return invokeInternal(adapterAndRest, Objects.requireNonNull(cancellationToken, "cancellationToken"), true); + } + + private OpenCliResult invokeInternal(List adapterAndRest, + OpenCliCancellationToken cancellationToken, boolean explicitCancellation) { + long submittedAtNanos = System.nanoTime(); + long timeoutMillis = properties.getCommandTimeoutMillis(); + List tokens = OpenCliArgSupport.snapshotValues(adapterAndRest, "adapterAndRest"); + if (tokens.isEmpty()) { + throw new IllegalArgumentException("adapterAndRest must contain at least the command identifier"); + } + if (OpenCliStrings.isBlank(tokens.get(0))) { + throw new IllegalArgumentException("adapterAndRest[0] command identifier must not be blank"); + } if (properties.getExecutionTarget() == OpenCliExecutionTarget.REMOTE_AGENT_HTTP) { - log.debug("OpenCLI invoke remote agent argvSize={}", adapterAndRest.size()); - OpenCliCollectRequest req = - OpenCliArgvToCollectParser.parse( - adapterAndRest, - properties.getRemoteOutputFormat(), - properties.getRemoteCollectMode(), - properties.getRemoteCdpEndpoint()); + if (explicitCancellation) { + throw new UnsupportedOperationException("Explicit process cancellation is local-only for legacy collect"); + } + OpenCliCollectRequest req = OpenCliArgvToCollectParser.parse(tokens, + properties.getRemoteOutputFormat(), properties.getRemoteCollectMode(), properties.getRemoteCdpEndpoint()); return remoteAgent().collect(req); } - log.debug("OpenCLI invoke local argvSize={}", adapterAndRest.size()); - CommandLine cmd = buildCommandLine(adapterAndRest); - return run(cmd); + CommandLine commandLine = buildCommandLine(tokens); + return run(commandLine, timeoutMillis, submittedAtNanos, cancellationToken); } - /** - * @return 远程 Agent HTTP 客户端(懒加载) - */ private OpenCliRemoteAgentHttpClient remoteAgent() { - if (Objects.isNull(remoteAgentHttpClient)) { + if (remoteAgentHttpClient == null) { synchronized (this) { - if (Objects.isNull(remoteAgentHttpClient)) { + if (remoteAgentHttpClient == null) { remoteAgentHttpClient = new OpenCliRemoteAgentHttpClient(properties); } } @@ -117,211 +104,107 @@ private OpenCliRemoteAgentHttpClient remoteAgent() { return remoteAgentHttpClient; } - /** - * 便捷重载:可变参数形式。 - * - * @param adapterAndRest adapter 及后续 CLI token - * @return 执行结果 - */ + /** @param adapterAndRest command and literal values @return execution result */ public OpenCliResult invoke(String... adapterAndRest) { - List list = new ArrayList<>(); - if (Objects.nonNull(adapterAndRest)) { - for (String s : adapterAndRest) { - if (OpenCliStrings.isNotBlank(s)) { - list.add(s.trim()); - } - } - } - return invoke(list); + Objects.requireNonNull(adapterAndRest, "adapterAndRest"); + return invoke(Arrays.asList(adapterAndRest)); } - /** - * 拼装 {@link CommandLine}:executable + leading + tokens。 - */ - private CommandLine buildCommandLine(List adapterAndRest) { - if (adapterAndRest.isEmpty()) { - throw new IllegalArgumentException("adapterAndRest must contain at least the adapter id"); - } - String exe = properties.getExecutable(); - if (OpenCliStrings.isBlank(exe)) { + private CommandLine buildCommandLine(List tokens) { + String executable = properties.getExecutable(); + if (OpenCliStrings.isBlank(executable)) { throw new IllegalStateException("opencli.executable must not be blank"); } - CommandLine cmd = new CommandLine(exe.trim()); - appendCleanArgs(cmd, properties.getLeadingArguments()); - appendCleanArgs(cmd, adapterAndRest); + String normalized = executable.trim(); + String lower = normalized.toLowerCase(Locale.ROOT); + if (System.getProperty("os.name").startsWith("Windows") + && (lower.endsWith(".cmd") || lower.endsWith(".bat"))) { + throw new UnsupportedOperationException("Use a native node executable plus the CLI JavaScript path; batch shims are not literal argv transports"); + } + CommandLine cmd = new LiteralCommandLine(normalized); + appendLiteralArgs(cmd, properties.getLeadingArguments(), "leadingArguments"); + appendLiteralArgs(cmd, tokens, "adapterAndRest"); return cmd; } - private static void appendCleanArgs(CommandLine cmd, List args) { - if (Objects.isNull(args) || args.isEmpty()) { - return; - } - for (String a : args) { - if (OpenCliStrings.isNotBlank(a)) { - cmd.addArgument(a.trim(), false); - } - } + private static void appendLiteralArgs(CommandLine cmd, List values, String field) { + if (values == null) { return; } + for (String value : OpenCliArgSupport.snapshotValues(values, field)) { cmd.addArgument(value, false); } } - /** - * 将 {@code --key=value} 以句柄安全形式追加(含空格时由 Commons Exec 处理)。 - * - * @param cmd 命令行 - * @param key 必须以 {@code --} 开头 - * @param value 非空值 - */ + /** Legacy explicit-quoting helper; not used by the literal process path. */ public static void appendQuotedKeyValue(CommandLine cmd, String key, String value) { Objects.requireNonNull(key, "key"); Objects.requireNonNull(value, "value"); if (!key.startsWith("--")) { - throw new IllegalArgumentException("CLI key must start with '--', got: " + key); + throw new IllegalArgumentException("CLI key must start with '--'"); } String prefix = key.endsWith("=") ? key.substring(0, key.length() - 1) : key; cmd.addArgument(prefix + "=" + value, true); } - private OpenCliResult run(CommandLine commandLine) { - long timeoutMs = properties.getCommandTimeoutMillis(); - if (timeoutMs <= 0) { + private OpenCliResult run(CommandLine commandLine, long timeoutMillis, long submittedAtNanos, + OpenCliCancellationToken cancellationToken) { + if (timeoutMillis <= 0) { throw new IllegalStateException("opencli.command-timeout-millis must be positive"); } - - File workingDirectory = resolveWorkingDirectory(); - Map environment = buildEnvironment(); - SubprocessExecutionSupport.ExecutionRequest request = - new SubprocessExecutionSupport.ExecutionRequest( - commandLine, workingDirectory, environment, timeoutMs); - + SubprocessExecutionSupport.ExecutionRequest request = new SubprocessExecutionSupport.ExecutionRequest( + commandLine, resolveWorkingDirectory(), buildEnvironment(), timeoutMillis, + properties.getMaxStdoutBytes(), properties.getMaxStderrBytes(), properties.getCleanupGraceMillis(), + submittedAtNanos, cancellationToken); try { - SubprocessExecutionSupport.RunSession session = SubprocessExecutionSupport.execute(request); - return completeAfterWait( - commandLine, - timeoutMs, - session.getStdout(), - session.getStderr(), - session.getHandler(), - session.getWatchdog(), - session.isWaitTimedOut()); - } catch (IOException e) { - log.warn("OpenCLI spawn failed commandLine={}, message={}", commandLine, e.getMessage()); - throw new OpenCliExecutableFailureException( - "OpenCLI could not be started (check PATH or executable path): " + commandLine, e); - } catch (InterruptedException e) { + return complete(processRuntime.execute(request)); + } catch (IOException ex) { + throw new OpenCliExecutableFailureException("OpenCLI process could not be started", ex); + } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - log.warn("OpenCLI interrupted commandLine={}", commandLine); - throw new OpenCliException("Interrupted while awaiting OpenCLI subprocess", e, null); + throw new OpenCliException("Interrupted while awaiting OpenCLI subprocess", ex, null); } } + private OpenCliResult complete(SubprocessExecutionSupport.RunSession session) { + String stdout = new String(session.getStdout().toByteArray(), StandardCharsets.UTF_8); + String stderr = new String(session.getStderr().toByteArray(), StandardCharsets.UTF_8); + OpenCliExecutionDetails details = session.getExecutionDetails(); + TerminationReason reason = details.getTerminationReason(); + Integer exit = session.getObservedExitCode(); + boolean success = reason == TerminationReason.PROCESS_EXIT && Integer.valueOf(0).equals(exit) + && details.isStreamsDrained(); + OpenCliResult result = OpenCliResult.builder().stdout(stdout).stderr(stderr).exitCode(exit) + .success(success).parsed(OpenCliOutputParser.parseBestEffort(stdout, stderr)) + .executionDetails(details).build(); + if (success) { return result; } + if (reason == TerminationReason.QUEUE_TIMEOUT || reason == TerminationReason.EXECUTION_TIMEOUT) { + throw new OpenCliTimeoutException("OpenCLI deadline exceeded: " + reason, result); + } + if (reason == TerminationReason.SPAWN_FAILED) { + throw new OpenCliExecutableFailureException("OpenCLI process could not be started", session.getIoFailure(), result); + } + if (reason == TerminationReason.PROCESS_EXIT && exit != null && exit != 0) { + throw new OpenCliNonZeroExitException("OpenCLI returned nonzero exitCode=" + exit, result); + } + throw new OpenCliException("OpenCLI execution ended: " + reason, session.getIoFailure(), result); + } + private File resolveWorkingDirectory() { - String wdProperty = properties.getWorkingDirectory(); - if (OpenCliStrings.isNotBlank(wdProperty)) { - File wd = new File(wdProperty.trim()); - if (!wd.isDirectory()) { - throw new OpenCliExecutableFailureException( - "opencli.working-directory is not an existing directory: " + wd.getAbsolutePath(), null); + String value = properties.getWorkingDirectory(); + if (OpenCliStrings.isNotBlank(value)) { + File directory = new File(value.trim()); + if (!directory.isDirectory()) { + throw new OpenCliExecutableFailureException("opencli.working-directory is not an existing directory", null); } - return wd; + return directory; } return null; } - private OpenCliResult completeAfterWait( - CommandLine commandLine, - long timeoutMs, - ByteArrayOutputStream out, - ByteArrayOutputStream err, - DefaultExecuteResultHandler handler, - ExecuteWatchdog watchdog, - boolean waitTimedOut) { - String stdoutStr = new String(out.toByteArray(), StandardCharsets.UTF_8); - String stderrStr = new String(err.toByteArray(), StandardCharsets.UTF_8); - OpenCliParsedFields parsed = OpenCliOutputParser.parseBestEffort(stdoutStr, stderrStr); - - if (waitTimedOut || watchdog.killedProcess()) { - log.warn("OpenCLI timed out commandLine={} timeoutMs={}", commandLine, timeoutMs); - OpenCliResult partial = snapshot(stdoutStr, stderrStr, readExitQuietly(handler), parsed); - throw new OpenCliTimeoutException( - "OpenCLI timed out after " + timeoutMs + " ms: " + commandLine, partial); - } - - Exception asyncFailure = handler.getException(); - if (asyncFailure instanceof ExecuteException) { - ExecuteException ex = (ExecuteException) asyncFailure; - log.warn("OpenCLI failed exitCode={} commandLine={}", ex.getExitValue(), commandLine); - OpenCliResult failed = snapshot(stdoutStr, stderrStr, normalizeExitValue(ex.getExitValue()), parsed); - throw new OpenCliNonZeroExitException( - "OpenCLI failed (exitCode=" + ex.getExitValue() + "): " + commandLine, failed); - } - if (Objects.nonNull(asyncFailure)) { - log.error("OpenCLI async failure commandLine={}", commandLine, asyncFailure); - OpenCliResult snapshot = snapshot(stdoutStr, stderrStr, readExitQuietly(handler), parsed); - throw new OpenCliException( - "OpenCLI async failure: " + commandLine + " cause=" + asyncFailure.getMessage(), - asyncFailure, snapshot); - } - - final int exit; - try { - exit = handler.getExitValue(); - } catch (IllegalStateException e) { - throw new OpenCliException( - "OpenCLI completed without observable exit code: " + commandLine, - e, - snapshot(stdoutStr, stderrStr, null, parsed)); - } - - if (exit != 0) { - log.warn("OpenCLI non-zero exit exitCode={} commandLine={}", exit, commandLine); - OpenCliResult failed = snapshot(stdoutStr, stderrStr, exit, parsed); - throw new OpenCliNonZeroExitException( - "OpenCLI non-zero exit (exitCode=" + exit + "): " + commandLine, failed); - } - - return OpenCliResult.builder() - .stdout(stdoutStr) - .stderr(stderrStr) - .exitCode(exit) - .success(true) - .parsed(parsed) - .build(); - } - private Map buildEnvironment() { Map env = new HashMap<>(System.getenv()); - if (Objects.nonNull(properties.getEnvironment())) { - for (Map.Entry e : properties.getEnvironment().entrySet()) { - if (Objects.nonNull(e.getKey()) && Objects.nonNull(e.getValue())) { - env.put(e.getKey(), e.getValue()); - } + if (properties.getEnvironment() != null) { + for (Map.Entry entry : new HashMap<>(properties.getEnvironment()).entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { env.put(entry.getKey(), entry.getValue()); } } } return env; } - - private static Integer readExitQuietly(DefaultExecuteResultHandler handler) { - try { - return normalizeExitValue(handler.getExitValue()); - } catch (IllegalStateException e) { - return null; - } - } - - private static Integer normalizeExitValue(int raw) { - if (raw == org.apache.commons.exec.Executor.INVALID_EXITVALUE) { - return null; - } - return raw; - } - - private static OpenCliResult snapshot( - String stdoutStr, String stderrStr, Integer exitCode, OpenCliParsedFields parsed) { - return OpenCliResult.builder() - .stdout(Objects.isNull(stdoutStr) ? "" : stdoutStr) - .stderr(Objects.isNull(stderrStr) ? "" : stderrStr) - .exitCode(exitCode) - .success(false) - .parsed(parsed) - .build(); - } } diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java new file mode 100644 index 0000000..5e5277e --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java @@ -0,0 +1,73 @@ +package io.github.easy4j.opencli.core; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * One immutable, schema-checked option occurrence. Preserve occurrence ordering + * by adding these to a request builder rather than using a Map for repeated flags. + * + * @author Loong Wan + * @since 3.0.0 + */ +public final class OpenCliOption { + private final OpenCliOptionSchema schema; + private final String value; + private final boolean negated; + + private OpenCliOption(OpenCliOptionSchema schema, String value, boolean negated) { + this.schema = schema; + this.value = value; + this.negated = negated; + } + + /** + * @param schema a valued-option definition + * @param value non-null value; false is the literal value "false", not absence + * @return an occurrence capturing the value immediately + */ + public static OpenCliOption value(OpenCliOptionSchema schema, Object value) { + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(value, "value"); + if (schema.getKind() != OpenCliOptionSchema.Kind.VALUE) { + throw new IllegalArgumentException("A flag schema cannot consume a value"); + } + return new OpenCliOption(schema, String.valueOf(value), false); + } + + /** @param schema a flag definition @return explicit positive presence */ + public static OpenCliOption present(OpenCliOptionSchema schema) { + Objects.requireNonNull(schema, "schema"); + if (schema.getKind() == OpenCliOptionSchema.Kind.VALUE) { + throw new IllegalArgumentException("A valued option requires a value"); + } + return new OpenCliOption(schema, null, false); + } + + /** @param schema a negatable flag definition @return an explicit negative flag */ + public static OpenCliOption negated(OpenCliOptionSchema schema) { + Objects.requireNonNull(schema, "schema"); + if (schema.getKind() != OpenCliOptionSchema.Kind.NEGATABLE_FLAG) { + throw new IllegalArgumentException("This option schema does not declare negation"); + } + return new OpenCliOption(schema, null, true); + } + + /** @return immutable input definition */ + public OpenCliOptionSchema getSchema() { return schema; } + + /** @return the captured value, or null for a flag */ + public String getValue() { return value; } + + /** @return whether this is explicit negative presence */ + public boolean isNegated() { return negated; } + + /** @return immutable literal tokens; no quoting or trimming is applied */ + public List toTokens() { + String flag = negated ? "--no-" + schema.getName().substring(2) : schema.getName(); + return value == null ? Collections.singletonList(flag) + : Collections.unmodifiableList(Arrays.asList(flag, value)); + } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java new file mode 100644 index 0000000..3289973 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java @@ -0,0 +1,73 @@ +package io.github.easy4j.opencli.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Explicit option input semantics. Definitions come from a known command contract, + * not guesses about a raw argument vector. This is not an output schema. + * + * @author Loong Wan + * @since 3.0.0 + */ +public final class OpenCliOptionSchema { + /** Input arity and negation semantics. */ + public enum Kind { FLAG, VALUE, NEGATABLE_FLAG } + + private static final Pattern NAME = Pattern.compile("(?:--[A-Za-z0-9][A-Za-z0-9-]*|-[A-Za-z0-9])"); + private final String name; + private final Kind kind; + private final boolean repeatable; + + private OpenCliOptionSchema(String name, Kind kind, boolean repeatable) { + Objects.requireNonNull(name, "name"); + if (!NAME.matcher(name).matches()) { + throw new IllegalArgumentException("Option schema requires a valid flag identifier"); + } + if (kind == Kind.NEGATABLE_FLAG && (!name.startsWith("--") || name.startsWith("--no-"))) { + throw new IllegalArgumentException("Negatable schema requires a positive long flag identifier"); + } + this.name = name; + this.kind = kind; + this.repeatable = repeatable; + } + + /** @param name flag identifier @return a presence-only, nonrepeatable flag */ + public static OpenCliOptionSchema flag(String name) { + return new OpenCliOptionSchema(name, Kind.FLAG, false); + } + + /** + * @param name option identifier + * @param repeatable whether repeated occurrences are accepted by the command + * @return an option taking one literal value per occurrence + */ + public static OpenCliOptionSchema value(String name, boolean repeatable) { + return new OpenCliOptionSchema(name, Kind.VALUE, repeatable); + } + + /** @param name positive long flag identifier @return a flag supporting explicit negation */ + public static OpenCliOptionSchema negatableFlag(String name) { + return new OpenCliOptionSchema(name, Kind.NEGATABLE_FLAG, false); + } + + /** @return canonical flag identifier */ + public String getName() { return name; } + + /** @return declared input kind */ + public Kind getKind() { return kind; } + + /** @return whether the command accepts repeated occurrences */ + public boolean isRepeatable() { return repeatable; } + + @Override + public boolean equals(Object other) { + if (this == other) { return true; } + if (!(other instanceof OpenCliOptionSchema)) { return false; } + OpenCliOptionSchema that = (OpenCliOptionSchema) other; + return name.equals(that.name) && kind == that.kind && repeatable == that.repeatable; + } + + @Override + public int hashCode() { return Objects.hash(name, kind, repeatable); } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java new file mode 100644 index 0000000..d373f64 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java @@ -0,0 +1,37 @@ +package io.github.easy4j.opencli.core; + +import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport; +import java.io.IOException; + +/** + * Stable process-capacity owner. Pass the same instance to multiple executors to + * share a limit deliberately; constructing an unrelated executor cannot replace it. + */ +public final class OpenCliProcessRuntime { + private final SubprocessExecutionSupport.Runtime runtime; + + /** @param maxConcurrent positive limit, or zero for the CPU-derived default; negative is invalid */ + public OpenCliProcessRuntime(int maxConcurrent) { + runtime = new SubprocessExecutionSupport.Runtime(maxConcurrent); + } + + /** @return fixed capacity for this runtime */ + public int getMaxConcurrentExecutions() { return runtime.getMaxConcurrentExecutions(); } + + /** @return whether unconfirmed resource cleanup has quarantined this runtime */ + public boolean isQuarantined() { return runtime.isQuarantined(); } + + /** + * Low-level bridge used by the SDK executor. Results retain bounded process evidence; + * the executor maps terminal conditions to the existing SDK exception hierarchy. + * + * @param request immutable submission snapshot + * @return terminal execution evidence + * @throws IOException retained for compatibility with the low-level execution API + * @throws InterruptedException retained for compatibility; observed interruption is normally returned as CANCELLED + */ + public SubprocessExecutionSupport.RunSession execute(SubprocessExecutionSupport.ExecutionRequest request) + throws IOException, InterruptedException { + return runtime.execute(request); + } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java index 2dbfd7a..b1fd2e4 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java @@ -5,45 +5,23 @@ import lombok.Getter; /** - * 单次 OpenCLI 调用的原始结果载体。 - *

- * {@link #remoteRawHttpBody} 仅在 {@link io.github.easy4j.opencli.OpenCliExecutionTarget#REMOTE_AGENT_HTTP} - * 且 {@link io.github.easy4j.opencli.OpenCliProperties} 的 {@code remoteCaptureRawHttpResponse} 为 true 时填充, - * 为 Agent 返回的完整 HTTP 响应体,便于审计或与 {@code stdout}(由 {@code items} 重组)对照。 - *

- */ -@Getter -@Builder/** - - * Raw result carrier for a single OpenCLI invocation. - * - *

{@link #remoteRawHttpBody} is only populated when using - * {@link io.github.easy4j.opencli.OpenCliExecutionTarget#REMOTE_AGENT_HTTP} - * and {@link io.github.easy4j.opencli.OpenCliProperties#isRemoteCaptureRawHttpResponse()} - * is {@code true}.

- + * Raw result for one OpenCLI invocation. Raw output is business data, not a safe + * diagnostic string. Local execution adds bounded lifecycle evidence; legacy + * remote responses do not acquire an invented process exit or cleanup state. * - * @author Loong Wan - * @since 3.0.0 - */ - +@Getter +@Builder public class OpenCliResult { - private final String stdout; - private final String stderr; - private final Integer exitCode; - private final boolean success; - private final OpenCliParsedFields parsed; - - /** - * 远端 Agent HTTP 响应全文;本地子进程模式或非调试场景下为 null。 - */ + /** Only populated by explicit remote HTTP raw capture. */ private final String remoteRawHttpBody; + /** Observed local execution metadata; null for a legacy remote result. */ + private final OpenCliExecutionDetails executionDetails; } diff --git a/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java b/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java index f6d77bb..5334915 100644 --- a/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java +++ b/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java @@ -1,167 +1,386 @@ package io.github.easy4j.opencli.core.support; -import lombok.Getter; -import org.apache.commons.exec.CommandLine; -import org.apache.commons.exec.DefaultExecuteResultHandler; -import org.apache.commons.exec.DefaultExecutor; -import org.apache.commons.exec.ExecuteWatchdog; -import org.apache.commons.exec.PumpStreamHandler; - +import io.github.easy4j.opencli.core.OpenCliCancellationToken; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.CleanupState; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; -import java.time.Duration; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import org.apache.commons.exec.CommandLine; +import org.apache.commons.exec.DefaultExecuteResultHandler; +import org.apache.commons.exec.ExecuteException; +import org.apache.commons.exec.ExecuteWatchdog; +import java.time.Duration; /** - * Subprocess execution support based on Apache Commons Exec: watchdog timeout, - * bounded {@code waitFor}, and concurrency throttling. - * - * @author Loong Wan - * @since 3.0.0 - */public final class SubprocessExecutionSupport { - - /** Watchdog 触发后,handler 收尾等待的上限(毫秒)。 */ + * Bounded, owned native-process execution. ProcessBuilder receives an exact argv + * vector, never a shell string. Commons Exec handler/watchdog views remain for + * compatibility with the earlier low-level RunSession API. + */ +public final class SubprocessExecutionSupport { public static final long WAIT_GRACE_MILLIS = 5_000L; + public static final int DEFAULT_STDOUT_LIMIT = 8 * 1024 * 1024; + public static final int DEFAULT_STDERR_LIMIT = 2 * 1024 * 1024; + private static final long POLL_NANOS = TimeUnit.MILLISECONDS.toNanos(10L); + private static final int DEFAULT_MAX_CONCURRENT = Math.max(2, java.lang.Runtime.getRuntime().availableProcessors()); + private static final Object LEGACY_LOCK = new Object(); + private static Runtime legacyRuntime = new Runtime(0); + private static int legacySubmissions; - private static final int DEFAULT_MAX_CONCURRENT = Math.max(2, Runtime.getRuntime().availableProcessors()); - - private static final AtomicReference CONCURRENCY_LIMIT = - new AtomicReference<>(new Semaphore(DEFAULT_MAX_CONCURRENT)); - - private SubprocessExecutionSupport() { - } + private SubprocessExecutionSupport() { } /** - * 配置本机 CLI 子进程全局并发上限;{@code maxConcurrent <= 0} 时恢复为默认值。 + * Configure only the deprecated static bridge, never an existing SDK client. + * Reconfiguration with active or queued submissions is rejected rather than + * creating a second live permit pool. * - * @param maxConcurrent 允许同时运行的子进程数 + * @param maxConcurrent positive capacity or zero for the default + * @deprecated pass an explicit OpenCliProcessRuntime to executors instead */ + @Deprecated public static void configureMaxConcurrentExecutions(int maxConcurrent) { - if (maxConcurrent <= 0) { - CONCURRENCY_LIMIT.set(new Semaphore(DEFAULT_MAX_CONCURRENT)); - return; + synchronized (LEGACY_LOCK) { + if (legacySubmissions != 0) { + throw new IllegalStateException("Cannot reconfigure the legacy runtime while submissions exist"); + } + legacyRuntime = new Runtime(maxConcurrent); } - CONCURRENCY_LIMIT.set(new Semaphore(maxConcurrent)); } - /** - * @return 未显式配置时的默认并发上限 - */ - public static int defaultMaxConcurrentExecutions() { - return DEFAULT_MAX_CONCURRENT; - } + public static int defaultMaxConcurrentExecutions() { return DEFAULT_MAX_CONCURRENT; } - /** - * 在并发许可内启动子进程并阻塞至结束、超时或被强制销毁。 - */ + /** Legacy entry point using one stable, explicitly configured runtime. */ public static RunSession execute(ExecutionRequest request) throws IOException, InterruptedException { Objects.requireNonNull(request, "request"); - Semaphore limit = CONCURRENCY_LIMIT.get(); - limit.acquire(); + Runtime selected; + synchronized (LEGACY_LOCK) { + selected = legacyRuntime; + legacySubmissions++; + } try { - return executeWithinLimit(request); + return selected.execute(request); } finally { - limit.release(); + synchronized (LEGACY_LOCK) { legacySubmissions--; } } } - private static RunSession executeWithinLimit(ExecutionRequest request) throws IOException, InterruptedException { - long timeoutMs = Math.max(1L, request.getTimeoutMillis()); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); + /** Internal capacity/cleanup owner exposed through OpenCliProcessRuntime. */ + public static final class Runtime { + private final int maxConcurrentExecutions; + private final Semaphore permits; + private volatile boolean quarantined; - DefaultExecutor.Builder builder = DefaultExecutor.builder(); - if (request.getWorkingDirectory() != null) { - builder.setWorkingDirectory(request.getWorkingDirectory()); + public Runtime(int maxConcurrent) { + if (maxConcurrent < 0) { + throw new IllegalArgumentException("maxConcurrentExecutions must not be negative"); + } + maxConcurrentExecutions = maxConcurrent == 0 ? DEFAULT_MAX_CONCURRENT : maxConcurrent; + permits = new Semaphore(maxConcurrentExecutions, true); } - DefaultExecutor executor = builder.get(); - executor.setStreamHandler(new PumpStreamHandler(out, err)); - - ExecuteWatchdog watchdog = - ExecuteWatchdog.builder().setTimeout(Duration.ofMillis(timeoutMs)).get(); - executor.setWatchdog(watchdog); - - DefaultExecuteResultHandler handler = new DefaultExecuteResultHandler(); - Map environment = request.getEnvironment(); - if (environment != null) { - executor.execute(request.getCommandLine(), environment, handler); - } else { - executor.execute(request.getCommandLine(), handler); + + public int getMaxConcurrentExecutions() { return maxConcurrentExecutions; } + public boolean isQuarantined() { return quarantined; } + + public RunSession execute(ExecutionRequest request) throws IOException, InterruptedException { + Objects.requireNonNull(request, "request"); + long budget = nanos(request.timeoutMillis, "timeoutMillis"); + BoundedCapture out = new BoundedCapture(request.stdoutLimitBytes); + BoundedCapture err = new BoundedCapture(request.stderrLimitBytes); + Process process = null; + Reader stdoutReader = null; + Reader stderrReader = null; + ExecuteWatchdog watchdog = ExecuteWatchdog.builder() + .setTimeout(Duration.ofMillis(request.timeoutMillis)).get(); + TerminationReason reason = null; + IOException ioFailure = null; + boolean acquired = false; + boolean interrupted = false; + long queueWaitMillis = 0L; + try { + while (!acquired && reason == null) { + if (Thread.interrupted()) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } else if (request.cancellationToken.isCancelled()) { + reason = TerminationReason.CANCELLED; + } else if (quarantined) { + reason = TerminationReason.RUNTIME_UNAVAILABLE; + } else { + long remaining = remaining(request.submittedAtNanos, budget); + if (remaining <= 0) { + reason = TerminationReason.QUEUE_TIMEOUT; + } else { + acquired = permits.tryAcquire(Math.min(POLL_NANOS, remaining), TimeUnit.NANOSECONDS); + } + } + } + queueWaitMillis = elapsedMillis(request.submittedAtNanos); + if (acquired && reason == null) { + if (Thread.interrupted()) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } else if (request.cancellationToken.isCancelled()) { + reason = TerminationReason.CANCELLED; + } else if (quarantined) { + reason = TerminationReason.RUNTIME_UNAVAILABLE; + } else if (remaining(request.submittedAtNanos, budget) <= 0) { + reason = TerminationReason.QUEUE_TIMEOUT; + } + } + if (acquired && reason == null) { + ProcessBuilder builder = new ProcessBuilder(request.nativeArgv); + if (request.workingDirectory != null) { builder.directory(request.workingDirectory); } + if (request.environment != null) { + builder.environment().clear(); + builder.environment().putAll(request.environment); + } + process = builder.start(); + process.getOutputStream().close(); + stdoutReader = new Reader(process.getInputStream(), out, "opencli-stdout"); + stderrReader = new Reader(process.getErrorStream(), err, "opencli-stderr"); + stdoutReader.start(); + stderrReader.start(); + long remaining = remaining(request.submittedAtNanos, budget); + watchdog = ExecuteWatchdog.builder().setTimeout(Duration.ofMillis( + Math.max(1L, TimeUnit.NANOSECONDS.toMillis(Math.max(0L, remaining))))).get(); + watchdog.start(process); + while (reason == null) { + if (out.isTruncated() || err.isTruncated()) { + reason = TerminationReason.OUTPUT_LIMIT; + } else if (Thread.interrupted()) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } else if (request.cancellationToken.isCancelled()) { + reason = TerminationReason.CANCELLED; + } else if (stdoutReader.failure != null || stderrReader.failure != null) { + reason = TerminationReason.IO_FAILURE; + } else if (!process.isAlive() && !stdoutReader.isAlive() && !stderrReader.isAlive()) { + reason = watchdog.killedProcess() ? TerminationReason.EXECUTION_TIMEOUT : TerminationReason.PROCESS_EXIT; + } else if (watchdog.killedProcess() || remaining(request.submittedAtNanos, budget) <= 0) { + reason = TerminationReason.EXECUTION_TIMEOUT; + } else { + TimeUnit.NANOSECONDS.sleep(Math.min(POLL_NANOS, Math.max(1L, + remaining(request.submittedAtNanos, budget)))); + } + } + } + } catch (InterruptedException ex) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } catch (IOException ex) { + ioFailure = ex; + reason = process == null ? TerminationReason.SPAWN_FAILED : TerminationReason.IO_FAILURE; + } finally { + watchdog.stop(); + long cleanupStart = System.nanoTime(); + long cleanupBudget = nanos(request.cleanupGraceMillis, "cleanupGraceMillis"); + if (process != null) { + if (process.isAlive()) { process.destroy(); } + boolean forceSent = false; + while (remaining(cleanupStart, cleanupBudget) > 0 + && (process.isAlive() || alive(stdoutReader) || alive(stderrReader))) { + if (Thread.interrupted()) { interrupted = true; } + if (process.isAlive() && !forceSent + && System.nanoTime() - cleanupStart >= Math.min(TimeUnit.MILLISECONDS.toNanos(100L), cleanupBudget / 2)) { + process.destroyForcibly(); + forceSent = true; + } + try { + TimeUnit.NANOSECONDS.sleep(Math.min(POLL_NANOS, + Math.max(1L, remaining(cleanupStart, cleanupBudget)))); + } catch (InterruptedException ex) { + interrupted = true; + } + } + if (process.isAlive()) { process.destroyForcibly(); } + if (process.isAlive() || alive(stdoutReader) || alive(stderrReader)) { + // A fixed runtime cannot accumulate unlimited uncertain children/readers. + quarantined = true; + if (reason == TerminationReason.PROCESS_EXIT) { reason = TerminationReason.CLEANUP_UNCONFIRMED; } + } + } + out.freeze(); + err.freeze(); + if (acquired) { permits.release(); } + if (interrupted) { Thread.currentThread().interrupt(); } + } + if (reason == TerminationReason.PROCESS_EXIT && (out.isTruncated() || err.isTruncated())) { + reason = TerminationReason.OUTPUT_LIMIT; + } + Integer exit = process != null && !process.isAlive() ? process.exitValue() : null; + DefaultExecuteResultHandler handler = new DefaultExecuteResultHandler(); + if (ioFailure != null) { + handler.onProcessFailed(new ExecuteException("Native process I/O failure", exit == null ? -1 : exit, ioFailure)); + } else if (exit != null && exit != 0) { + handler.onProcessFailed(new ExecuteException("Native process returned nonzero status", exit)); + } else if (exit != null) { + handler.onProcessComplete(exit); + } + OpenCliExecutionDetails details = OpenCliExecutionDetails.builder() + .terminationReason(reason) + .cleanupState(process == null ? CleanupState.NOT_STARTED + : process.isAlive() ? CleanupState.UNCONFIRMED : CleanupState.ROOT_EXIT_CONFIRMED) + .processStarted(process != null).streamsDrained(!alive(stdoutReader) && !alive(stderrReader)) + .stdoutCapturedBytes(out.size()).stdoutObservedBytes(out.observed()).stdoutTruncated(out.isTruncated()) + .stderrCapturedBytes(err.size()).stderrObservedBytes(err.observed()).stderrTruncated(err.isTruncated()) + .elapsedMillis(elapsedMillis(request.submittedAtNanos)).queueWaitMillis(queueWaitMillis) + .descendantsExitConfirmed(false).build(); + return new RunSession(out, err, handler, watchdog, request.timeoutMillis, + reason == TerminationReason.QUEUE_TIMEOUT || reason == TerminationReason.EXECUTION_TIMEOUT, + details, exit, ioFailure); + } + } + + private static boolean alive(Thread thread) { return thread != null && thread.isAlive(); } + private static long remaining(long start, long budget) { return budget - (System.nanoTime() - start); } + private static long elapsedMillis(long start) { return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); } + + private static long nanos(long millis, String field) { + if (millis <= 0 || millis > Long.MAX_VALUE / 1_000_000L) { + throw new IllegalArgumentException(field + " must be positive and fit a monotonic nanosecond budget"); + } + return millis * 1_000_000L; + } + + private static final class BoundedCapture extends ByteArrayOutputStream { + private final int limit; + private long observed; + private boolean truncated; + private boolean frozen; + + BoundedCapture(int limit) { + super(Math.min(8192, limit)); + this.limit = limit; } - boolean finished = awaitResult(handler, timeoutMs + WAIT_GRACE_MILLIS); - boolean waitTimedOut = !finished; - if (waitTimedOut) { - watchdog.destroyProcess(); - awaitResult(handler, WAIT_GRACE_MILLIS); + @Override + public synchronized void write(byte[] bytes, int offset, int length) { + if (frozen) { return; } + observed = observed > Long.MAX_VALUE - length ? Long.MAX_VALUE : observed + length; + int retained = Math.min(length, limit - count); + super.write(bytes, offset, retained); + truncated |= retained < length; } - return new RunSession(out, err, handler, watchdog, timeoutMs, waitTimedOut); + @Override + public synchronized void write(int value) { write(new byte[]{(byte) value}, 0, 1); } + synchronized boolean isTruncated() { return truncated; } + synchronized long observed() { return observed; } + synchronized void freeze() { frozen = true; } } - private static boolean awaitResult(DefaultExecuteResultHandler handler, long timeoutMillis) - throws InterruptedException { - long deadline = System.currentTimeMillis() + Math.max(1L, timeoutMillis); - while (!handler.hasResult()) { - if (System.currentTimeMillis() >= deadline) { - return false; + private static final class Reader extends Thread { + private final InputStream input; + private final BoundedCapture capture; + private volatile IOException failure; + + Reader(InputStream input, BoundedCapture capture, String name) { + super(name); + this.input = input; + this.capture = capture; + setDaemon(true); + } + + @Override + public void run() { + try (InputStream stream = input) { + byte[] buffer = new byte[8192]; + int size; + while ((size = stream.read(buffer)) != -1) { + capture.write(buffer, 0, size); + if (capture.isTruncated()) { return; } + } + } catch (IOException ex) { + failure = ex; } - Thread.sleep(Math.min(50L, deadline - System.currentTimeMillis())); } - return true; } @Getter public static final class ExecutionRequest { - private final CommandLine commandLine; + private final List nativeArgv; private final File workingDirectory; private final Map environment; private final long timeoutMillis; + private final int stdoutLimitBytes; + private final int stderrLimitBytes; + private final long cleanupGraceMillis; + private final long submittedAtNanos; + private final OpenCliCancellationToken cancellationToken; - public ExecutionRequest( - CommandLine commandLine, - File workingDirectory, - Map environment, - long timeoutMillis) { + public ExecutionRequest(CommandLine commandLine, File workingDirectory, + Map environment, long timeoutMillis) { + this(commandLine, workingDirectory, environment, timeoutMillis, + DEFAULT_STDOUT_LIMIT, DEFAULT_STDERR_LIMIT, WAIT_GRACE_MILLIS, + System.nanoTime(), new OpenCliCancellationToken()); + } + + public ExecutionRequest(CommandLine commandLine, File workingDirectory, + Map environment, long timeoutMillis, int stdoutLimitBytes, + int stderrLimitBytes, long cleanupGraceMillis, long submittedAtNanos, + OpenCliCancellationToken cancellationToken) { this.commandLine = Objects.requireNonNull(commandLine, "commandLine"); + List argv = new ArrayList<>(Arrays.asList(commandLine.toStrings())); + for (int i = 0; i < argv.size(); i++) { + if (argv.get(i) == null) { throw new IllegalArgumentException("nativeArgv[" + i + "] must not be null"); } + } + nativeArgv = Collections.unmodifiableList(argv); this.workingDirectory = workingDirectory; - this.environment = environment; + this.environment = environment == null ? null : Collections.unmodifiableMap(new HashMap<>(environment)); + nanos(timeoutMillis, "timeoutMillis"); + nanos(cleanupGraceMillis, "cleanupGraceMillis"); + if (stdoutLimitBytes <= 0 || stderrLimitBytes <= 0) { + throw new IllegalArgumentException("stdout/stderr capture budgets must be positive"); + } this.timeoutMillis = timeoutMillis; + this.stdoutLimitBytes = stdoutLimitBytes; + this.stderrLimitBytes = stderrLimitBytes; + this.cleanupGraceMillis = cleanupGraceMillis; + this.submittedAtNanos = submittedAtNanos; + this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken"); } } @Getter public static final class RunSession { - private final ByteArrayOutputStream stdout; private final ByteArrayOutputStream stderr; private final DefaultExecuteResultHandler handler; private final ExecuteWatchdog watchdog; private final long timeoutMillis; private final boolean waitTimedOut; + private final OpenCliExecutionDetails executionDetails; + private final Integer observedExitCode; + private final IOException ioFailure; - RunSession( - ByteArrayOutputStream stdout, - ByteArrayOutputStream stderr, - DefaultExecuteResultHandler handler, - ExecuteWatchdog watchdog, - long timeoutMillis, - boolean waitTimedOut) { + RunSession(ByteArrayOutputStream stdout, ByteArrayOutputStream stderr, + DefaultExecuteResultHandler handler, ExecuteWatchdog watchdog, long timeoutMillis, + boolean waitTimedOut, OpenCliExecutionDetails executionDetails, + Integer observedExitCode, IOException ioFailure) { this.stdout = stdout; this.stderr = stderr; this.handler = handler; this.watchdog = watchdog; this.timeoutMillis = timeoutMillis; this.waitTimedOut = waitTimedOut; + this.executionDetails = executionDetails; + this.observedExitCode = observedExitCode; + this.ioFailure = ioFailure; } - public boolean timedOut() { - return waitTimedOut || watchdog.killedProcess(); - } + public boolean timedOut() { return waitTimedOut; } } } diff --git a/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java b/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java index 378bd95..270f2c7 100644 --- a/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java +++ b/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java @@ -2,14 +2,14 @@ import io.github.easy4j.opencli.core.OpenCliResult; -/** - * Thrown when the OpenCLI executable cannot be started (PATH, permissions, invalid arguments, etc.). - * - * @author Loong Wan - * @since 3.0.0 - */public class OpenCliExecutableFailureException extends OpenCliException { - +/** Failure to start the configured executable. */ +public class OpenCliExecutableFailureException extends OpenCliException { public OpenCliExecutableFailureException(String message, Throwable cause) { super(message, cause, null); } + + /** Retains a no-process-started snapshot without inventing an exit code. */ + public OpenCliExecutableFailureException(String message, Throwable cause, OpenCliResult partialResult) { + super(message, cause, partialResult); + } } diff --git a/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java b/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java new file mode 100644 index 0000000..3c50a12 --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java @@ -0,0 +1,16 @@ +package io.github.easy4j.opencli.contract; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** Offline child process: emits every actual JVM argument, including empty values. */ +public final class ContractProbe { + private ContractProbe() { } + + public static void main(String[] args) { + System.out.println("argc:" + args.length); + for (String arg : args) { + System.out.println("arg:" + Base64.getEncoder().encodeToString(arg.getBytes(StandardCharsets.UTF_8))); + } + } +} diff --git a/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java new file mode 100644 index 0000000..88247cf --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java @@ -0,0 +1,44 @@ +package io.github.easy4j.opencli.contract; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; + +/** Offline, self-bounded child fixture. Release files allow cleanup even against a broken SDK. */ +public final class LifecycleProbe { + private LifecycleProbe() { } + + public static void main(String[] args) throws Exception { + String mode = args[0]; + if ("utf8".equals(mode)) { + System.out.write("中文".getBytes(StandardCharsets.UTF_8)); + return; + } + if ("stdout".equals(mode) || "stderr".equals(mode)) { + byte[] block = new byte[8192]; + Arrays.fill(block, (byte) 'x'); + int remaining = Integer.parseInt(args[1]); + while (remaining > 0) { + int size = Math.min(block.length, remaining); + if ("stdout".equals(mode)) { System.out.write(block, 0, size); } + else { System.err.write(block, 0, size); } + remaining -= size; + } + return; + } + Path marker = Paths.get(args[1]); + Files.write(marker, "started".getBytes(StandardCharsets.UTF_8)); + if ("write".equals(mode)) { return; } + Path release = Paths.get(args[2]); + long start = System.nanoTime(); + int tick = 0; + while (!Files.exists(release) && System.nanoTime() - start < 10_000_000_000L) { + if ("heartbeat".equals(mode)) { + Files.write(marker, Integer.toString(++tick).getBytes(StandardCharsets.UTF_8)); + } + Thread.sleep(20L); + } + } +} diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java new file mode 100644 index 0000000..e50a631 --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java @@ -0,0 +1,171 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.browser.OpenCliBrowserClient; +import io.github.easy4j.opencli.core.OpenCliAdapterChannel; +import io.github.easy4j.opencli.core.OpenCliAdapterCommandRequest; +import io.github.easy4j.opencli.core.OpenCliArgSupport; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import static org.junit.jupiter.api.Assertions.*; + +/** Shared Java 8-compatible contract tests, with a real child rather than Recording executor. */ +class OpenCliArgvContractTest { + private static OpenCliProperties properties() { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + ContractProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + return p; + } + + private static List received(OpenCliResult result) throws Exception { + assertTrue(result.isSuccess()); + List actual = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new StringReader(result.getStdout()))) { + String header = reader.readLine(); + assertNotNull(header, "child produced no argv evidence"); + assertTrue(header.startsWith("argc:"), "missing child protocol header"); + int count = Integer.parseInt(header.substring(5)); + for (int i = 0; i < count; i++) { + String line = reader.readLine(); + assertNotNull(line, "incomplete child output"); + assertTrue(line.startsWith("arg:")); + actual.add(new String(Base64.getDecoder().decode(line.substring(4)), StandardCharsets.UTF_8)); + } + assertNull(reader.readLine(), "unexpected extra child output"); + } + return actual; + } + + @TestFactory + Stream sharedVectorsReachEveryRawEntryPoint() throws Exception { + List tests = new ArrayList<>(); + InputStream resource = getClass().getResourceAsStream("/opencli-contracts/v1/argv.tsv"); + assertNotNull(resource, "shared fixture is required"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String[] fields = line.split("\t", -1); + List expected = new ArrayList<>(); + for (int i = 1; i < fields.length; i++) { + expected.add(new String(Base64.getDecoder().decode(fields[i]), StandardCharsets.UTF_8)); + } + for (int mode = 0; mode < 4; mode++) { + final int entry = mode; + tests.add(DynamicTest.dynamicTest(fields[0] + "/entry-" + mode, () -> { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + List before = new ArrayList<>(expected); + OpenCliResult result; + if (entry == 0) { + result = executor.invoke(expected); + } else if (entry == 1) { + result = executor.invoke(expected.toArray(new String[0])); + } else { + OpenCliAdapterChannel channel = new OpenCliAdapterChannel(executor, expected.get(0)); + List rest = expected.subList(1, expected.size()); + result = entry == 2 ? channel.invoke(rest) : channel.invoke(rest.toArray(new String[0])); + } + assertEquals(before, expected, "caller list changed"); + assertEquals(expected, received(result), "full argv changed at child boundary"); + })); + } + } + } + assertEquals(24, tests.size(), "fixture denominator changed; review sources.lock.json"); + return tests.stream(); + } + + @Test + void structuredPositionalsAndValuesReachChildUnchanged() throws Exception { + Map options = new LinkedHashMap<>(); + options.put("text", " value "); + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder() + .subcommand("echo").positional("").positional(" positional ").options(options).build(); + assertEquals(Arrays.asList("demo", "echo", "", " positional ", "--text", " value "), + received(new OpenCliAdapterChannel(new OpenCliExecutor(properties()), "demo").invoke(request))); + } + + @Test + void typedBrowserFillCanClearAField() throws Exception { + OpenCliBrowserClient browser = new OpenCliBrowserClient(new OpenCliExecutor(properties())); + assertEquals(Arrays.asList("browser", "contract", "fill", "#input", ""), + received(browser.session("contract").fill("#input", "", null, null))); + } + + @Test + void rawMergePreservesEmptyAndPaddedValues() throws Exception { + List prefix = Arrays.asList("demo", "echo", ""); + List extra = Arrays.asList(" x ", "--", "-literal"); + assertEquals(Arrays.asList("demo", "echo", "", " x ", "--", "-literal"), + received(new OpenCliExecutor(properties()).invoke(OpenCliArgSupport.merge(prefix, extra)))); + assertEquals(Arrays.asList("demo", "echo", ""), prefix); + } + + @Test + void leadingArgumentsPreserveEmptyAndPaddedValues() throws Exception { + OpenCliProperties p = properties(); + p.getLeadingArguments().add(""); + p.getLeadingArguments().add(" leading "); + assertEquals(Arrays.asList("", " leading ", "demo"), + received(new OpenCliExecutor(p).invoke("demo"))); + } + + @Test + void nullRawTokenIsRejectedWithoutLeakingOtherTokens() { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> executor.invoke(Arrays.asList("demo", "SECRET_MARKER", null))); + assertTrue(error.getMessage().contains("2"), "validation must identify null index"); + assertFalse(error.getMessage().contains("SECRET_MARKER")); + } + + @Test + void blankCommandIdentifierIsRejectedBeforeSpawn() { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + assertThrows(IllegalArgumentException.class, () -> executor.invoke(Arrays.asList("", "demo"))); + assertThrows(IllegalArgumentException.class, () -> OpenCliAdapterCommandRequest.builder() + .subcommand(" ").build().toSubcommandAndArgs()); + } + + @Test + void nullVarargsContainerIsRejectedConsistently() { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + assertThrows(NullPointerException.class, () -> executor.invoke((String[]) null)); + assertThrows(NullPointerException.class, + () -> new OpenCliAdapterChannel(executor, "demo").invoke((String[]) null)); + } + + @Test + void nullLeadingTokenIsRejectedBeforeSpawn() { + OpenCliProperties p = properties(); + p.getLeadingArguments().add(null); + assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(p).invoke("demo")); + } + + @Test + void nullMergedTokenIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> OpenCliArgSupport.merge(Arrays.asList("demo", null), Collections.emptyList())); + } +} diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java new file mode 100644 index 0000000..389140c --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java @@ -0,0 +1,162 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliCancellationToken; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import io.github.easy4j.opencli.exception.OpenCliException; +import io.github.easy4j.opencli.exception.OpenCliTimeoutException; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +/** Additional finite-budget and immutable-submission regression vectors. */ +@Timeout(20) +class OpenCliProcessBoundaryTest { + @TempDir Path dir; + + private static OpenCliProperties properties() { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + LifecycleProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + p.setMaxConcurrentExecutions(1); + return p; + } + + private static void await(Path path) throws Exception { + long started = System.nanoTime(); + while (!Files.exists(path) && System.nanoTime() - started < TimeUnit.SECONDS.toNanos(3L)) { Thread.sleep(10L); } + assertTrue(Files.exists(path), "fixture did not start"); + } + + @Test + void utf8TruncationReportsBytesNotReencodedCharacters() { + OpenCliProperties p = properties(); + p.setMaxStdoutBytes(4); + OpenCliException failure = assertThrows(OpenCliException.class, () -> new OpenCliExecutor(p).invoke("utf8")); + OpenCliResult partial = failure.getPartialResult(); + assertNotNull(partial); + assertEquals(TerminationReason.OUTPUT_LIMIT, partial.getExecutionDetails().getTerminationReason()); + assertEquals(4L, partial.getExecutionDetails().getStdoutCapturedBytes()); + assertEquals(6L, partial.getExecutionDetails().getStdoutObservedBytes()); + assertEquals("中\uFFFD", partial.getStdout()); + assertTrue(partial.getExecutionDetails().isStdoutTruncated()); + } + + @Test + void invalidBudgetsFailBeforeChildCreation() { + for (int choice = 0; choice < 5; choice++) { + OpenCliProperties p = properties(); + if (choice == 0) { p.setMaxStdoutBytes(0); } + if (choice == 1) { p.setMaxStderrBytes(-1); } + if (choice == 2) { p.setCleanupGraceMillis(0); } + if (choice == 3) { p.setCommandTimeoutMillis(Long.MAX_VALUE); } + if (choice == 4) { p.setCleanupGraceMillis(Long.MAX_VALUE); } + Path marker = dir.resolve("invalid-" + choice); + assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(p).invoke("write", marker.toString())); + assertFalse(Files.exists(marker)); + } + } + + @Test + void explicitCancellationOfRunningChildRetainsBoundedEvidence() throws Exception { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + OpenCliCancellationToken token = new OpenCliCancellationToken(); + Path heartbeat = dir.resolve("heartbeat"); + Path release = dir.resolve("release"); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future future = worker.submit(() -> executor.invoke( + Arrays.asList("heartbeat", heartbeat.toString(), release.toString()), token)); + await(heartbeat); + token.cancel(); + ExecutionException failed = assertThrows(ExecutionException.class, () -> future.get(3, TimeUnit.SECONDS)); + assertTrue(failed.getCause() instanceof OpenCliException); + OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult(); + assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason()); + assertTrue(partial.getExecutionDetails().isProcessStarted()); + assertFalse(partial.getExecutionDetails().isDescendantsExitConfirmed()); + String stopped = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8); + Thread.sleep(100L); + assertEquals(stopped, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8)); + assertTrue(executor.invoke("write", dir.resolve("next").toString()).isSuccess()); + } finally { + Files.write(release, new byte[]{1}); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void queuedTokenCancellationDoesNotStartTheWaitingChild() throws Exception { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + Path first = dir.resolve("first"); + Path second = dir.resolve("second"); + Path gate = dir.resolve("release"); + OpenCliCancellationToken token = new OpenCliCancellationToken(); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future one = workers.submit(() -> executor.invoke("hold", first.toString(), gate.toString())); + await(first); + Future two = workers.submit(() -> executor.invoke(Arrays.asList("write", second.toString()), token)); + Thread.sleep(100L); + token.cancel(); + ExecutionException failed = assertThrows(ExecutionException.class, () -> two.get(2, TimeUnit.SECONDS)); + OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult(); + assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason()); + assertFalse(partial.getExecutionDetails().isProcessStarted()); + assertNull(partial.getExitCode()); + assertFalse(Files.exists(second)); + Files.write(gate, new byte[]{1}); + assertTrue(one.get(3, TimeUnit.SECONDS).isSuccess()); + } finally { + Files.write(gate, new byte[]{1}); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void timeoutAndCaptureConfigurationAreCopiedForReverseWorkers() { + OpenCliProperties p = properties(); + p.setMaxStdoutBytes(123); + p.setMaxStderrBytes(456); + p.setCleanupGraceMillis(789); + OpenCliProperties copy = p.copyForLocalCliExecution(); + assertEquals(123, copy.getMaxStdoutBytes()); + assertEquals(456, copy.getMaxStderrBytes()); + assertEquals(789L, copy.getCleanupGraceMillis()); + } + + @Test + void repeatedExecutionTimeoutsDoNotBecomeNonzeroOrIoFailures() { + OpenCliProperties p = properties(); + p.setCommandTimeoutMillis(100L); + OpenCliExecutor executor = new OpenCliExecutor(p); + for (int i = 0; i < 5; i++) { + Path marker = dir.resolve("timeout-" + i); + OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class, + () -> executor.invoke("hold", marker.toString(), dir.resolve("never-release").toString())); + assertEquals(TerminationReason.EXECUTION_TIMEOUT, failure.getPartialResult().getExecutionDetails().getTerminationReason()); + assertFalse(executor.getProcessRuntime().isQuarantined()); + } + } +} diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java new file mode 100644 index 0000000..5cb4397 --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java @@ -0,0 +1,233 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import io.github.easy4j.opencli.exception.OpenCliException; +import io.github.easy4j.opencli.exception.OpenCliTimeoutException; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +/** C02 tests observe actual fixture processes, not private semaphore counters. */ +@Timeout(20) +class OpenCliProcessContractTest { + @TempDir Path dir; + + private static OpenCliProperties properties(int maxConcurrent) { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + LifecycleProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + p.setMaxConcurrentExecutions(maxConcurrent); + return p; + } + + private static boolean awaitFile(Path path, long millis) throws Exception { + long start = System.nanoTime(); + while (System.nanoTime() - start < TimeUnit.MILLISECONDS.toNanos(millis)) { + if (Files.exists(path)) { return true; } + Thread.sleep(10L); + } + return Files.exists(path); + } + + private static void release(Path path) { + try { Files.write(path, new byte[]{1}); } + catch (Exception ex) { throw new AssertionError("fixture cleanup failed", ex); } + } + + private static Object getter(Object object, String name) { + assertNotNull(object, "partial evidence is required"); + return assertDoesNotThrow(() -> object.getClass().getMethod(name).invoke(object), + "required execution evidence is missing: " + name); + } + + private static Object details(OpenCliResult result) { return getter(result, "getExecutionDetails"); } + + @Test + void anotherClientCannotReplaceAnActiveClientsLimiter() throws Exception { + OpenCliExecutor a = new OpenCliExecutor(properties(1)); + Path first = dir.resolve("first"); + Path second = dir.resolve("second"); + Path gate = dir.resolve("release"); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString())); + assertTrue(awaitFile(first, 3000), "first child did not start"); + new OpenCliExecutor(properties(4)); + Future two = workers.submit(() -> a.invoke("write", second.toString())); + assertFalse(awaitFile(second, 600), "constructing B bypassed A's active limiter"); + release(gate); + assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess()); + assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess()); + } finally { + release(gate); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void queuedDeadlineExpiresWithoutSpawning() throws Exception { + OpenCliProperties p = properties(1); + OpenCliExecutor executor = new OpenCliExecutor(p); + Path first = dir.resolve("first"); + Path second = dir.resolve("must-not-start"); + Path gate = dir.resolve("release"); + ExecutorService worker = Executors.newSingleThreadExecutor(); + ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor(); + try { + Future one = worker.submit(() -> executor.invoke("hold", first.toString(), gate.toString())); + assertTrue(awaitFile(first, 3000)); + p.setCommandTimeoutMillis(50L); + cleanup.schedule(() -> release(gate), 1500L, TimeUnit.MILLISECONDS); + long started = System.nanoTime(); + OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class, + () -> executor.invoke("write", second.toString())); + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + assertTrue(elapsed < 750L, "queue wait ignored total deadline: " + elapsed); + assertFalse(Files.exists(second), "queue-expired child was started"); + Object evidence = details(failure.getPartialResult()); + assertEquals("QUEUE_TIMEOUT", String.valueOf(getter(evidence, "getTerminationReason"))); + assertEquals(false, getter(evidence, "isProcessStarted")); + assertNull(failure.getPartialResult().getExitCode()); + release(gate); + assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess()); + } finally { + release(gate); + worker.shutdownNow(); + cleanup.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(cleanup.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void stdoutDefaultBudgetFailsInsteadOfReturningUnboundedSuccess() { + OpenCliException failure = assertThrows(OpenCliException.class, + () -> new OpenCliExecutor(properties(1)).invoke("stdout", Integer.toString(9 * 1024 * 1024))); + OpenCliResult partial = failure.getPartialResult(); + Object evidence = details(partial); + assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason"))); + assertEquals(8L * 1024 * 1024, ((Number) getter(evidence, "getStdoutCapturedBytes")).longValue()); + assertTrue(((Number) getter(evidence, "getStdoutObservedBytes")).longValue() > 8L * 1024 * 1024); + assertEquals(true, getter(evidence, "isStdoutTruncated")); + assertFalse(partial.isSuccess()); + } + + @Test + void stderrHasItsOwnSmallerBudget() { + OpenCliException failure = assertThrows(OpenCliException.class, + () -> new OpenCliExecutor(properties(1)).invoke("stderr", Integer.toString(3 * 1024 * 1024))); + Object evidence = details(failure.getPartialResult()); + assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason"))); + assertEquals(2L * 1024 * 1024, ((Number) getter(evidence, "getStderrCapturedBytes")).longValue()); + assertEquals(true, getter(evidence, "isStderrTruncated")); + } + + @Test + void interruptionStopsTheOwnedHeartbeatAndRestoresFlag() throws Exception { + OpenCliExecutor executor = new OpenCliExecutor(properties(1)); + Path heartbeat = dir.resolve("heartbeat"); + Path gate = dir.resolve("release"); + AtomicBoolean restored = new AtomicBoolean(); + AtomicReference error = new AtomicReference<>(); + Thread caller = new Thread(() -> { + try { executor.invoke("heartbeat", heartbeat.toString(), gate.toString()); } + catch (Throwable failure) { error.set(failure); restored.set(Thread.currentThread().isInterrupted()); } + }, "contract-interrupted-caller"); + try { + caller.start(); + assertTrue(awaitFile(heartbeat, 3000)); + Thread.sleep(80L); + caller.interrupt(); + caller.join(1500L); + assertFalse(caller.isAlive(), "interrupted call did not finish cleanup"); + assertTrue(error.get() instanceof OpenCliException); + assertTrue(restored.get(), "caller interrupt flag was lost"); + String observed = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8); + Thread.sleep(250L); + assertEquals(observed, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8), + "owned child kept running after interrupted call returned"); + OpenCliException failure = (OpenCliException) error.get(); + assertEquals("CANCELLED", String.valueOf(getter(details(failure.getPartialResult()), "getTerminationReason"))); + assertEquals("ROOT_EXIT_CONFIRMED", String.valueOf(getter(details(failure.getPartialResult()), "getCleanupState"))); + Path next = dir.resolve("next"); + assertTrue(executor.invoke("write", next.toString()).isSuccess(), "permit leaked after cancellation"); + } finally { + release(gate); + caller.interrupt(); + caller.join(5000L); + } + } + + @Test + void negativeConcurrencyIsNotSilentlyTreatedAsDefault() { + assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(properties(-1))); + } + + @Test + void explicitSharedRuntimeLimitsBothClients() throws Exception { + Class runtimeType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliProcessRuntime")); + Object runtime = runtimeType.getConstructor(int.class).newInstance(1); + OpenCliExecutor a = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType) + .newInstance(properties(4), runtime); + OpenCliExecutor b = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType) + .newInstance(properties(4), runtime); + Path first = dir.resolve("shared-first"); + Path second = dir.resolve("shared-second"); + Path gate = dir.resolve("release"); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString())); + assertTrue(awaitFile(first, 3000)); + Future two = workers.submit(() -> b.invoke("write", second.toString())); + assertFalse(awaitFile(second, 500), "shared runtime did not enforce its one permit"); + release(gate); + assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess()); + assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess()); + } finally { + release(gate); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void preCancelledRequestNeverStartsAProcess() throws Exception { + Class tokenType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliCancellationToken")); + Object token = tokenType.getConstructor().newInstance(); + tokenType.getMethod("cancel").invoke(token); + Path marker = dir.resolve("pre-cancelled"); + OpenCliExecutor executor = new OpenCliExecutor(properties(1)); + java.lang.reflect.InvocationTargetException failure = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> OpenCliExecutor.class.getMethod("invoke", List.class, tokenType) + .invoke(executor, Arrays.asList("write", marker.toString()), token)); + assertTrue(failure.getCause() instanceof OpenCliException); + OpenCliResult partial = ((OpenCliException) failure.getCause()).getPartialResult(); + assertEquals("CANCELLED", String.valueOf(getter(details(partial), "getTerminationReason"))); + assertEquals(false, getter(details(partial), "isProcessStarted")); + assertNull(partial.getExitCode()); + assertFalse(Files.exists(marker)); + } +} diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java new file mode 100644 index 0000000..c23685d --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java @@ -0,0 +1,180 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliAdapterChannel; +import io.github.easy4j.opencli.core.OpenCliAdapterCommandRequest; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** Public binary/API contract: missing methods fail assertions rather than preventing the RED build. */ +class OpenCliStructuredArgvContractTest { + private static Class type(String simpleName) { + return assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core." + simpleName), + "required ordered-option API is not implemented"); + } + + private static Object call(Class owner, Object receiver, String name, Class[] parameterTypes, Object... args) { + Method method = assertDoesNotThrow(() -> owner.getMethod(name, parameterTypes), + "required public method is not implemented: " + name); + try { + return method.invoke(receiver, args); + } catch (InvocationTargetException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new AssertionError("unexpected checked failure", cause); + } catch (ReflectiveOperationException ex) { + throw new AssertionError("public API is inaccessible", ex); + } + } + + private static Object schema(String form, String name, boolean repeatable) { + Class owner = type("OpenCliOptionSchema"); + return "value".equals(form) + ? call(owner, null, form, new Class[]{String.class, boolean.class}, name, repeatable) + : call(owner, null, form, new Class[]{String.class}, name); + } + + private static Object value(Object definition, Object value) { + return call(type("OpenCliOption"), null, "value", + new Class[]{type("OpenCliOptionSchema"), Object.class}, definition, value); + } + + private static Object flag(String form, Object definition) { + return call(type("OpenCliOption"), null, form, + new Class[]{type("OpenCliOptionSchema")}, definition); + } + + private static void add(Object builder, Object occurrence) { + call(builder.getClass(), builder, "option", new Class[]{type("OpenCliOption")}, occurrence); + } + + private static List run(OpenCliAdapterCommandRequest request) { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + ContractProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + OpenCliResult result = new OpenCliAdapterChannel(new OpenCliExecutor(p), "demo").invoke(request); + assertTrue(result.isSuccess()); + String[] lines = result.getStdout().split("\r?\n"); + List actual = new ArrayList<>(); + for (int i = 1; i < lines.length; i++) { + assertTrue(lines[i].startsWith("arg:")); + actual.add(new String(Base64.getDecoder().decode(lines[i].substring(4)), StandardCharsets.UTF_8)); + } + assertEquals("argc:" + actual.size(), lines[0]); + return actual; + } + + @Test + void orderedRepeatedValuesAndExplicitFalseReachChild() { + Object tags = schema("value", "--tag", true); + Object enabled = schema("value", "--enabled", false); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, value(tags, " A ")); + add(b, value(enabled, false)); + add(b, value(tags, "")); + assertEquals(Arrays.asList("demo", "echo", "--tag", " A ", "--enabled", "false", "--tag", ""), run(b.build())); + } + + @Test + void explicitNegationIsDifferentFromAbsenceAndPresence() { + Object cache = schema("negatableFlag", "--cache", false); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, flag("negated", cache)); + assertEquals(Arrays.asList("demo", "echo", "--no-cache"), run(b.build())); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder present = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(present, flag("present", cache)); + assertEquals(Arrays.asList("demo", "echo", "--cache"), run(present.build())); + assertEquals(Arrays.asList("demo", "echo"), run(OpenCliAdapterCommandRequest.builder().subcommand("echo").build())); + } + + @Test + void flagSchemaCannotSilentlyConsumeAValue() { + Object verbose = schema("flag", "--verbose", false); + assertThrows(IllegalArgumentException.class, () -> value(verbose, false)); + assertThrows(IllegalArgumentException.class, () -> flag("negated", verbose)); + } + + @Test + void nonrepeatableOptionCannotAppearTwice() { + Object once = schema("value", "--limit", false); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, value(once, 1)); + add(b, value(once, 2)); + assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs()); + } + + @Test + void legacyAndOrderedOptionsCannotCollide() { + Object limit = schema("value", "--limit", true); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo") + .options(Collections.singletonMap("limit", "1")); + add(b, value(limit, "2")); + assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs()); + } + + @Test + void mutableOccurrenceValueIsCapturedWhenCreated() { + StringBuilder text = new StringBuilder(" before "); + Object occurrence = value(schema("value", "--text", false), text); + text.append("after"); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, occurrence); + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(b.build())); + } + + @Test + void malformedSchemaIdentifiersAreRejected() { + Class owner = type("OpenCliOptionSchema"); + assertThrows(IllegalArgumentException.class, + () -> call(owner, null, "flag", new Class[]{String.class}, "--x y")); + assertThrows(IllegalArgumentException.class, + () -> call(owner, null, "flag", new Class[]{String.class}, "")); + } + + @Test + void legacyMapIsCapturedRatherThanAliased() { + Map options = new LinkedHashMap<>(); + options.put("text", " before "); + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo").options(options).build(); + options.put("text", "after"); + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request)); + } + + @Test + void mutableLegacyValueCannotChangeAnExistingRequest() { + StringBuilder text = new StringBuilder(" before "); + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo") + .options(Collections.singletonMap("text", text)).build(); + text.append("after"); + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request)); + } + + @Test + void reusingBuilderDoesNotMutatePreviousRequest() { + Object tags = schema("value", "--tag", true); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, value(tags, "A")); + OpenCliAdapterCommandRequest first = b.build(); + add(b, value(tags, "B")); + assertEquals(Arrays.asList("demo", "echo", "--tag", "A"), run(first)); + } +} diff --git a/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java b/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java index 8f2c36b..d98b0f5 100644 --- a/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java +++ b/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java @@ -116,7 +116,7 @@ void shouldReturnImmutableOptions() { } @Test - void shouldSkipNullPositionalInToSubcommandAndArgs() { + void shouldPreserveEmptyPositionalInToSubcommandAndArgs() { OpenCliAdapterCommandRequest req = OpenCliAdapterCommandRequest.builder() .subcommand("sub") .positional("a") @@ -124,7 +124,7 @@ void shouldSkipNullPositionalInToSubcommandAndArgs() { .positional("b") .build(); List argv = req.toSubcommandAndArgs(); - assertEquals(Arrays.asList("sub", "a", "b"), argv); + assertEquals(Arrays.asList("sub", "a", "", "b"), argv); } @Test diff --git a/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java b/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java index f5efc59..977ec52 100644 --- a/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java +++ b/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java @@ -15,9 +15,15 @@ void shouldMergeNonNullLists() { } @Test - void shouldFilterNullAndBlankWhenMerging() { - List result = OpenCliArgSupport.merge(Arrays.asList("a", null, "", " ", "b"), Arrays.asList("c")); - assertEquals(Arrays.asList("a", "b", "c"), result); + void shouldRejectNullElementWhenMerging() { + assertThrows(IllegalArgumentException.class, + () -> OpenCliArgSupport.merge(Arrays.asList("a", null, "", " ", "b"), Arrays.asList("c"))); + } + + @Test + void shouldPreserveEmptyAndBlankWhenMerging() { + assertEquals(Arrays.asList("a", "", " ", "b", "c"), + OpenCliArgSupport.merge(Arrays.asList("a", "", " ", "b"), Arrays.asList("c"))); } @Test diff --git a/src/test/resources/opencli-contracts/v1/SHA256SUMS b/src/test/resources/opencli-contracts/v1/SHA256SUMS new file mode 100644 index 0000000..c7a5e08 --- /dev/null +++ b/src/test/resources/opencli-contracts/v1/SHA256SUMS @@ -0,0 +1,2 @@ +e7e6dd57e81e24e92a7cb0de0e1204f95025f80310f5dc34d820aa7419636f78 argv.tsv +2fa30626c415bb3c152bc4c2827cd9282ba710654f6a56fb7bde70a83b2bc192 sources.lock.json diff --git a/src/test/resources/opencli-contracts/v1/argv.tsv b/src/test/resources/opencli-contracts/v1/argv.tsv new file mode 100644 index 0000000..bb4ce3a --- /dev/null +++ b/src/test/resources/opencli-contracts/v1/argv.tsv @@ -0,0 +1,6 @@ +ordinary ZGVtbw== ZWNobw== aGVsbG8= +empty ZGVtbw== ZWNobw== +whitespace ZGVtbw== ZWNobw== ICB4ICA= ICAg +unicode-newline ZGVtbw== ZWNobw== CuS4reaWhwo= 8J+Zgg== +literal-shell ZGVtbw== ZWNobw== JChwcmludGYgU0hPVUxEX05PVF9SVU4pOyAq LS0= LWxpdGVyYWw= +equals-quotes ZGVtbw== ZWNobw== LS1rZXk9YT1i ImxpdGVyYWwi Qzpc6Lev5b6EXGZpbGUgbmFtZQ== diff --git a/src/test/resources/opencli-contracts/v1/sources.lock.json b/src/test/resources/opencli-contracts/v1/sources.lock.json new file mode 100644 index 0000000..a1ed16b --- /dev/null +++ b/src/test/resources/opencli-contracts/v1/sources.lock.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "observedAt": "2026-09-21", + "scope": "Synthetic offline argv vectors; not captures from OpenCLI or websites.", + "source": { + "kind": "specification", + "requirements": [ + "OC-ARGV-001", + "OC-ARGV-002", + "OC-ARGV-004", + "OC-ARGV-005" + ], + "ref": "d0c8056990f7a47fcc202acffa387ba066bcfc67", + "path": "openspec/changes/harden-opencli-argv-contract/specs/opencli-argv-contract/spec.md" + }, + "files": [ + { + "path": "argv.tsv", + "sha256": "e7e6dd57e81e24e92a7cb0de0e1204f95025f80310f5dc34d820aa7419636f78", + "vectors": 6, + "encoding": "case-id then tab-separated base64 UTF-8 tokens; preserve trailing empty fields" + } + ], + "branches": [ + { + "branch": "feature/1.0.x", + "base": "abba809f11dae68437c39d2ea5a2f4cf8798c0ef", + "java": 8, + "jackson": 2, + "mavenMajor": 3 + }, + { + "branch": "feature/2.0.x", + "base": "d0c8056990f7a47fcc202acffa387ba066bcfc67", + "java": 17, + "jackson": 2, + "mavenMajor": 3, + "canonical": true + }, + { + "branch": "feature/3.0.x", + "base": "6e38904bdfcae90ec617e8d29bf3d8cf2f002893", + "java": 21, + "jackson": 3, + "mavenMajor": 4 + } + ], + "allowedDifferences": [ + "JDK baseline", + "Jackson imports/decoder internals", + "Maven wrapper/POM schema" + ], + "forbiddenDifferences": [ + "raw token contents/order/count", + "null validation", + "observable semantics of shared vectors" + ] +}