diff --git a/docs/language_status/register/X35.md b/docs/language_status/register/X35.md index d89c41742..339a317b2 100644 --- a/docs/language_status/register/X35.md +++ b/docs/language_status/register/X35.md @@ -50,6 +50,9 @@ reached: "partly (unit-proven on CicsTask and the stub C, which answer one step - **Refused by name** (the translators): CERTIFICATE, CODEPAGE, HTTPVNUM / HTTPRNUM, CIPHERS on OPEN; DOCTOKEN, CONTAINER, SET, NOTRUNCATE, TOCONTAINER, ACTION, CLOSESTATUS, AUTHENTICATE / USERNAME / PASSWORD, CHARACTERSET, BODYCHARSET on CONVERSE, and a CONVERSE without INTO; WEB SEND / RECEIVE / PARSE URL / EXTRACT / READ / WRITE HTTPHEADER stay refused whole. +- **WEB CONVERSE takes IBM's bare method keywords (#7525).** DELETE, GET, HEAD, OPTIONS, POST, PUT and TRACE are IBM's bare form of `METHOD(cvda)` + (exactly one of the eight; none or two refused by the spec group in both translators). Each resolves at translation time to the CVDA + number `METHOD(DFHVALUE(...))` gives, so the runtime and the stub are unchanged; TRACE / OPTIONS stay refused at run time by name. - **WEB CONVERSE URIMAP is modelled from the stated peers (#7525); NOTRUNCATE stays REFUSED.** URIMAP (IBM: a CLIENT URIMAP "applies only to this request", "do not specify the PATH or PATHLENGTH options"): `CicsTask.webConverse` takes the request's path from the URIMAP's `path` in `http_peers`, ASSUMED to be the URIMAP the session's own peer states (the run states a CLIENT URIMAP only as a diff --git a/gitgalaxy/standards/cics/commands/web.py b/gitgalaxy/standards/cics/commands/web.py index 19ec8b358..01e9f884c 100644 --- a/gitgalaxy/standards/cics/commands/web.py +++ b/gitgalaxy/standards/cics/commands/web.py @@ -50,6 +50,7 @@ _TOKEN_OUT = Arg("area_out", width=8) _TOKEN_IN = Arg("area_in", width=8) +METHOD_FLAGS = ("DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "TRACE") # #7525: IBM's bare form of METHOD(cvda) _CONVERT_FLAGS = ("CLICONVERT", "NOINCONVERT", "NOOUTCONVERT", "NOCLICONVERT") # the stated server(s): one fact both runtimes read (CicsTask.withHttpPeers, $GGCICS_HTTP) @@ -138,6 +139,7 @@ options={ "SESSTOKEN": _TOKEN_IN, "METHOD": Arg("cvda"), + **{f: Arg("flag") for f in METHOD_FLAGS}, "URIMAP": Arg("area_in", width=8), "PATH": Arg("area_in"), "PATHLENGTH": Arg("value"), @@ -173,13 +175,17 @@ "TOCONTAINER": Refusal("a response into a container is not modelled", "X35"), }, default_refusal=Refusal( - "only METHOD, URIMAP, PATH, QUERYSTRING, FROM, MEDIATYPE, INTO / MAXLENGTH / TOLENGTH, STATUSCODE / STATUSTEXT / " + "only METHOD (or its bare forms), URIMAP, PATH, QUERYSTRING, FROM, MEDIATYPE, INTO / MAXLENGTH / TOLENGTH, STATUSCODE / STATUSTEXT / " "STATUSLEN and CLIENTCONV are modelled", "X35", ), groups=( required("SESSTOKEN", msg="WEB CONVERSE without SESSTOKEN"), - required("METHOD", msg="WEB CONVERSE without METHOD"), + one_of( + "METHOD", + *METHOD_FLAGS, + msg="WEB CONVERSE without METHOD (or one of its bare forms DELETE, GET, HEAD, OPTIONS, POST, PUT, TRACE), or with more than one", + ), required("INTO", msg="WEB CONVERSE without INTO: only a response into the program's area is modelled"), requires(("PATH",), ("PATHLENGTH",), msg="WEB CONVERSE PATH without PATHLENGTH", both_ways=True), requires( diff --git a/gitgalaxy/tools/cobol_to_java/det/cics_web.py b/gitgalaxy/tools/cobol_to_java/det/cics_web.py index d8e9215c6..a9f796359 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cics_web.py +++ b/gitgalaxy/tools/cobol_to_java/det/cics_web.py @@ -11,6 +11,10 @@ _msg, _requires, ) +from gitgalaxy.tools.cobol_to_java.det.cvda import CVDA + +# #7525: IBM's bare forms of WEB CONVERSE METHOD(cvda); written out here as the spec's METHOD_FLAGS (a test pins them equal) +METHOD_WORDS = ("DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "TRACE") def code_page_pair_refusal(opts: dict[str, str | None], key: str) -> str | None: @@ -172,9 +176,11 @@ def web(self, key: str, opts: dict, ind: str) -> list[str]: return [f"{ind}CicsTask.WebOpened {r} = {call};", f"{ind}if ({r}.resp() == 0) DetCics.putText({self.field(_arg(opts['SESSTOKEN']))}, {r}.sesstoken(), CS);", *self.outcome(opts, f"{r}.resp()", f"{r}.resp2()", ind)] # fmt: skip - for o in ("METHOD", "INTO"): - if not opts.get(o): - raise CicsError(_msg(key, "required", o)) + bare = [f for f in METHOD_WORDS if f in opts] # #7525: IBM's bare form of METHOD(cvda) + if len(bare) + ("METHOD" in opts) != 1 or ("METHOD" in opts and not opts["METHOD"]): + raise CicsError(_msg(key, "one_of", "METHOD")) + if not opts.get("INTO"): + raise CicsError(_msg(key, "required", "INTO")) for a, n in (("PATH", "PATHLENGTH"), ("QUERYSTRING", "QUERYSTRLEN"), ("FROM", "FROMLENGTH"), ("STATUSTEXT", "STATUSLEN")): # fmt: skip if (a in opts) != (n in opts): @@ -192,8 +198,9 @@ def text(o: str) -> str: def num(o: str, absent: str) -> str: return self.int_(_arg(opts[o])) if o in opts else absent + method = str(CVDA[bare[0]]) if bare else num("METHOD", "0") into = _arg(opts["INTO"]) - call = (f"task.webConverse({text('SESSTOKEN')}, {text('URIMAP')}, {num('METHOD', '0')}, {text('PATH')}, {num('PATHLENGTH', '0')}, " + call = (f"task.webConverse({text('SESSTOKEN')}, {text('URIMAP')}, {method}, {text('PATH')}, {num('PATHLENGTH', '0')}, " f"{text('QUERYSTRING')}, {num('QUERYSTRLEN', '0')}, {text('FROM')}, {num('FROMLENGTH', '0')}, " f"{text('MEDIATYPE')}, {num('MAXLENGTH', str(self.size(into)))}, {num('STATUSLEN', '-1')}, CS)") # fmt: skip diff --git a/tests/cobol_mainframe/test_web_converse_bare_method_7525.py b/tests/cobol_mainframe/test_web_converse_bare_method_7525.py new file mode 100644 index 000000000..25a33ca00 --- /dev/null +++ b/tests/cobol_mainframe/test_web_converse_bare_method_7525.py @@ -0,0 +1,133 @@ +"""#7525 (register X35): EXEC CICS WEB CONVERSE with IBM's bare method keyword (DELETE, GET, HEAD, OPTIONS, POST, PUT, TRACE) +in place of METHOD(cvda). IBM (EXEC CICS WEB CONVERSE): the method is given by one of those keywords or by METHOD. Both +translators (the det port and the GnuCOBOL oracle's) resolve the keyword to its CVDA at translation time, so the runtime and +the stub see the same number as METHOD(DFHVALUE(x)); two methods, or none, are refused by the spec group. The end-to-end +proof reuses the URIMAP program of test_web_converse_urimap_e2e_7525.py with each METHOD(DFHVALUE(x)) written as the keyword.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from gitgalaxy.standards.cics.commands.web import METHOD_FLAGS, WEB_CONVERSE +from gitgalaxy.tools.cobol_to_java.det.cics_web import METHOD_WORDS +from gitgalaxy.tools.cobol_to_java.det.cvda import CVDA + +HERE = Path(__file__).resolve().parent +TOOLS = HERE.parents[0] / "tools" +sys.path.insert(0, str(HERE)) +import test_det_translate as t # noqa: E402 + +TAIL = " INTO(AREA) MAXLENGTH(R2) NOHANDLE" +BODY = " FROM(AREA) FROMLENGTH(R) MEDIATYPE(REC)" + + +def _det(method: str, extra: str = "") -> str: + return " ".join(t._ChanCics().command(f"WEB CONVERSE SESSTOKEN(KEY) {method}{extra}{TAIL}", "")) + + +def test_the_spec_names_the_seven_keywords_as_flags_of_the_method(): + assert METHOD_FLAGS == ("DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "TRACE") + assert METHOD_WORDS == METHOD_FLAGS + for f in METHOD_FLAGS: + assert WEB_CONVERSE.options[f].kind == "flag" + assert f not in WEB_CONVERSE.refused + + +@pytest.mark.parametrize("word", METHOD_FLAGS) +def test_det_bare_keyword_is_the_same_java_as_the_value_form(word): + assert _det(word) == _det("METHOD(R)").replace("INT(R)", str(CVDA[word]), 1) + assert f", {CVDA[word]}, " in _det(word) + + +def test_det_bare_keyword_with_a_body_and_with_a_path(): + assert _det("POST", BODY) == _det("METHOD(R)", BODY).replace("INT(R)", "636", 1) + assert ", 748, " in _det("GET", " PATH(REC) PATHLENGTH(R)") + + +@pytest.mark.parametrize( + ("cmd", "why"), + [ + ("WEB CONVERSE SESSTOKEN(KEY)" + TAIL, "without METHOD"), + ("WEB CONVERSE SESSTOKEN(KEY) GET POST" + TAIL, "or with more than one"), + ("WEB CONVERSE SESSTOKEN(KEY) METHOD(R) GET" + TAIL, "or with more than one"), + ("WEB CONVERSE SESSTOKEN(KEY) GET MAXLENGTH(R2)", "without INTO"), + ], +) +def test_det_refuses_no_method_and_two_methods(cmd, why): + with pytest.raises(t.C.CicsError, match=why): + t._ChanCics().command(cmd, "") + + +def test_oracle_translator_moves_the_cvda_and_refuses_as_the_det_port_does(): + pytest.importorskip("tree_sitter_language_pack") + sys.path.insert(0, str(TOOLS)) + import equivalence_cics as ec + + base = "WEB CONVERSE SESSTOKEN(WS-TOK) {} INTO(WS-IN) MAXLENGTH(WS-MAX)" + for word in METHOD_FLAGS: + bare = ec.translate_command(base.format(word)) + assert f"MOVE {CVDA[word]} TO GG-NUM" in bare + assert any("GGCWCNV" in line for line in bare) + assert bare == ec.translate_command(base.format(f"METHOD({CVDA[word]})")) + for bad in ("", "GET POST", "METHOD(WS-M) PUT"): + with pytest.raises(ec.Unsupported, match="METHOD"): + ec.translate_command(base.format(bad)) + + +def test_trace_and_options_stay_refused_by_name_by_the_runtime(): + # the keywords resolve to the numbers the runtime refuses (cics_task_web.java: 760 TRACE, 759 OPTIONS) + assert (CVDA["TRACE"], CVDA["OPTIONS"]) == (760, 759) + assert "WEB CONVERSE METHOD TRACE / OPTIONS" in ( + Path(__file__).resolve().parents[2] / "gitgalaxy/tools/cobol_to_java/cics_task_web.py" + ).read_text(encoding="utf-8") + + +@pytest.mark.slow(reason="a Maven build of the generated project and a GnuCOBOL container run") +@pytest.mark.skipif(os.environ.get("EQUIVALENCE_E2E") != "1", reason="needs Docker (GnuCOBOL) and a JDK + Maven") +def test_a_bare_keyword_program_is_proven_against_gnucobol(tmp_path, monkeypatch): + pytest.importorskip("tree_sitter_language_pack") + sys.path.insert(0, str(TOOLS)) + import det_port + import test_web_converse_urimap_e2e_7525 as e + + from gitgalaxy.tools.cobol_to_java.det import program as P + + program = e.PROGRAM + for w in ("POST", "PUT", "GET", "DELETE"): + program = program.replace(f"METHOD(DFHVALUE({w}))", w) + assert "DFHVALUE(PATCH)" in program and "DFHVALUE(POST)" not in program + estate = tmp_path / "estate" + (estate / "cbl").mkdir(parents=True) + (estate / "csd").mkdir() + (estate / "cbl" / "WCUDEMO.cbl").write_text(program, encoding="ascii") + (estate / "csd" / "WCU.csd").write_text(e.CSD, encoding="ascii") + for argv in (["init", "-q"], ["add", "-A"], ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "e"]): + subprocess.run(["git", "-C", str(estate), *argv], check=True) # noqa: S603, S607 + (tmp_path / "case.json").write_text(json.dumps(e._case()), encoding="utf-8") + project = det_port.estate(estate, tmp_path / "translate") + port = tmp_path / "port" + (port / "service").mkdir(parents=True) + stub = (project / "src/main/java" / det_port.PKG_DIR / "service" / "WcudemoService.java").read_text("utf-8") + r = P.translate( + estate / "cbl" / "WCUDEMO.cbl", [estate / "cbl"], stub, det_port.PKG, P.estate_files(project), project + ) + assert r.stats["holes"] == [], r.stats["holes"] + (port / "service" / f"{r.service}.java").write_text(r.java, encoding="utf-8") + for rel, text in P.runtime_files(det_port.PKG, P.has_batch(project)).items(): + (port / rel).parent.mkdir(parents=True, exist_ok=True) + (port / rel).write_text(text, encoding="utf-8") + keep = tmp_path / "proof" + proc = subprocess.run([sys.executable, str(TOOLS / "equivalence.py"), "run", "wcu-7525b", "--case-file", # noqa: S603 + str(tmp_path / "case.json"), "--corpus-dir", str(estate), "--port", str(port), "--keep", + str(keep), "--faults", "none"], capture_output=True, text=True, check=False) # fmt: skip + report_file = keep / "report.json" + report = json.loads(report_file.read_text(encoding="utf-8")) if report_file.is_file() else {} + assert proc.returncode == 0 and report.get("proven") is True, proc.stdout[-4000:] + proc.stderr[-3000:] + events = report["outputs"]["converse"]["cobol"] + assert [x["text"].rstrip() for x in events if x.get("event") == "SEND-TEXT"] == e.SENT diff --git a/tests/tools/equivalence_cics_web.py b/tests/tools/equivalence_cics_web.py index 1c137923d..619bee75d 100644 --- a/tests/tools/equivalence_cics_web.py +++ b/tests/tools/equivalence_cics_web.py @@ -10,8 +10,9 @@ from typing import Any import equivalence_cics as ec -from gitgalaxy.tools.cobol_to_java.det.cics_web import code_page_pair_refusal +from gitgalaxy.tools.cobol_to_java.det.cics_web import METHOD_WORDS, code_page_pair_refusal +from gitgalaxy.tools.cobol_to_java.det.cvda import CVDA _HTTP_PEER_KEYS = {"scheme", "host", "port", "urimap", "path", "exchanges"} _HTTP_X_KEYS = {"method", "path", "query", "status", "statustext", "mediatype", "body"} @@ -287,7 +288,7 @@ def _web_extract(opts: dict[str, str | None], labels: list[str] | None, key: str if opts.get("PORTNUMBER"): back.append(f" MOVE GG-NUM TO {opts['PORTNUMBER']}") if opts.get("SCHEME"): - back += [f" MOVE 1096 TO {opts['SCHEME']}", f" IF GG-NAME1(1:5) = 'HTTPS'", f" MOVE 1029 TO {opts['SCHEME']}", + back += [f" MOVE 1096 TO {opts['SCHEME']}", " IF GG-NAME1(1:5) = 'HTTPS'", f" MOVE 1029 TO {opts['SCHEME']}", " END-IF"] # fmt: skip if opts.get("REQUESTTYPE"): back.append(f" MOVE 1033 TO {opts['REQUESTTYPE']}") @@ -556,9 +557,11 @@ def _web(opts: dict[str, str | None], labels: list[str] | None) -> list[str]: "URIMAP" in opts and "PATH" in opts ): # (the spec group is not enforced by _check_spec: the det translator checks it too) raise ec.Unsupported(ec._rule(key, "at_most_one", "URIMAP"), [key]) - for o in ("METHOD", "INTO"): - if not opts.get(o): - raise ec.Unsupported(ec._rule(key, "required", o), [key]) + bare = [f for f in METHOD_WORDS if f in opts] # #7525: IBM's bare form of METHOD(cvda) + if len(bare) + ("METHOD" in opts) != 1 or ("METHOD" in opts and not opts["METHOD"]): + raise ec.Unsupported(ec._rule(key, "one_of", "METHOD"), [key]) + if not opts.get("INTO"): + raise ec.Unsupported(ec._rule(key, "required", "INTO"), [key]) for a, n in (("PATH", "PATHLENGTH"), ("QUERYSTRING", "QUERYSTRLEN"), ("FROM", "FROMLENGTH"), ("STATUSTEXT", "STATUSLEN")): # fmt: skip if (a in opts) != (n in opts): @@ -570,7 +573,7 @@ def _web(opts: dict[str, str | None], labels: list[str] | None) -> list[str]: if "CLIENTCONV" in opts and not opts["CLIENTCONV"]: raise ec.Unsupported(ec.NEEDS_ARGUMENT, [key]) into = opts["INTO"] - lines = [f"MOVE {opts['METHOD']} TO GG-NUM", f"MOVE {opts.get('MAXLENGTH') or f'LENGTH OF {into}'} TO GG-LEN", + lines = [f"MOVE {CVDA[bare[0]] if bare else opts['METHOD']} TO GG-NUM", f"MOVE {opts.get('MAXLENGTH') or f'LENGTH OF {into}'} TO GG-LEN", f"MOVE {opts.get('STATUSLEN') or '-1'} TO GG-ITEM"] # fmt: skip if opts.get("URIMAP"): # #7525: the name in GG-NAME1, GG-FLAGS names it with the other options given lines.append(f"MOVE {opts['URIMAP']} TO GG-NAME1")