| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Test slurmrestd's http_parser plugin. | |
| Exercises valid, malformed, binary, and pipelined HTTP requests against the | |
| http_parser plugin slurmrestd is configured to use (Issue 50084). | |
| """ | |
| import base64 | |
| import re | |
| import pytest | |
| import atf | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| atf.require_version( | |
| (26, 5), | |
| "sbin/slurmrestd", | |
| reason="Issue 50084: HttpParserType/UrlParserType configuration requires 25.11+ and patches for some tests only backported to 26.05+", | |
| ) | |
| # A pinned parser cannot fall back: if a variant selects llhttp_parser on a | |
| # slurmrestd that predates the plugin, http_parser_g_init() fails and | |
| # slurmrestd exits fatally, so skip rather than reporting every case as a | |
| # load failure. | |
| if "llhttp" in atf.get_config_parameter("HttpParserType", default="", live=False): | |
| atf.require_version( | |
| (26, 11), | |
| "sbin/slurmrestd", | |
| reason="Issue 50084: http_parser/llhttp_parser plugin requires 26.11+", | |
| ) | |
| atf.require_slurm_running() | |
| @pytest.fixture | |
| def http_parser(): | |
| """Return the http_parser plugin slurmrestd is configured to use. | |
| The test environment pins HttpParserType per variant, so tests read the | |
| active parser instead of forcing one. Only llhttp_parser (26.11+) defines | |
| ESLURM_HTTP_MISSING_CR. | |
| """ | |
| return atf.get_config_parameter( | |
| "HttpParserType", default="http_parser/libhttp_parser", live=False | |
| ) | |
| # expected_error is the slurmrestd error message expected in the rejection | |
| # response body, or None where the exact error classification is not reliably | |
| # predictable (bad version, truncated request). llhttp_only cases exercise an | |
| # error code that only exists in llhttp_parser. | |
| @pytest.mark.parametrize( | |
| "payload,expected_error,llhttp_only", | |
| [ | |
| pytest.param( | |
| "GET /openapi HTTP/1.1\r\nHost: x\n\r\n", | |
| "Missing an expected CR character", | |
| True, | |
| id="missing_cr", | |
| ), | |
| pytest.param( | |
| "GET /openapi HTTP/1.1\r\nHost: x\rY\r\n\r\n", | |
| "Missing an expected LF character", | |
| False, | |
| id="missing_lf", | |
| ), | |
| pytest.param( | |
| "GERP / HTTP/1.1\r\n\r\n", | |
| "Invalid HTTP method", | |
| False, | |
| id="invalid_method_chars", | |
| ), | |
| pytest.param( | |
| "get / HTTP/1.1\r\n\r\n", | |
| "Invalid HTTP method", | |
| False, | |
| id="lowercase_method", | |
| ), | |
| pytest.param("GET / HTTP/abc\r\n\r\n", None, False, id="bad_version_syntax"), | |
| pytest.param("GET / HTTP/9.9\r\n\r\n", None, False, id="bad_version_number"), | |
| pytest.param( | |
| "POST / HTTP/1.1\r\nContent-Length: abc\r\n\r\n", | |
| "Unexpected character or unparsable number in HTTP Content-Length header", | |
| False, | |
| id="bad_content_length", | |
| ), | |
| pytest.param( | |
| "POST / HTTP/1.1\r\nContent-Length: -1\r\n\r\n", | |
| "Unexpected character or unparsable number in HTTP Content-Length header", | |
| False, | |
| id="negative_content_length", | |
| ), | |
| pytest.param( | |
| "GET / HTTP/1.1\r\nX\x00Y: v\r\n\r\n", | |
| "Unexpected character added or missing in HTTP", | |
| False, | |
| id="null_in_header_name", | |
| ), | |
| pytest.param( | |
| "GET / HTTP/1.1\r\n", | |
| None, | |
| False, | |
| id="incomplete_request_eof", | |
| marks=pytest.mark.xfail( | |
| reason="Issue 51064: slurmrestd does not run llhttp_finish at " | |
| "read-EOF, so a truncated request is silently accepted (exit 0)", | |
| ), | |
| ), | |
| ], | |
| ) | |
| def test_bad_input(http_parser, payload, expected_error, llhttp_only): | |
| """Verify slurmrestd rejects malformed HTTP requests.""" | |
| if llhttp_only and "llhttp" not in http_parser: | |
| pytest.skip("Issue 50084: libhttp_parser has no ESLURM_HTTP_MISSING_CR") | |
| result = atf.run_command("slurmrestd -a rest_auth/local", input=payload, xfail=True) | |
| assert ( | |
| result["exit_code"] != 0 | |
| ), f"slurmrestd should have rejected malformed request: {payload!r}" | |
| # A reintroduced xassert abort (the cleanup crash these cases guard | |
| # against) logs an assertion failure to stderr before SIGABRT; the exit | |
| # code alone cannot tell a crash from a clean rejection here. | |
| assert ( | |
| "Assertion" not in result["stderr"] | |
| ), f"slurmrestd aborted instead of cleanly rejecting {payload!r}: {result['stderr']!r}" | |
| if expected_error is not None: | |
| assert expected_error in result["stdout"], ( | |
| f"slurmrestd rejected {payload!r} but the response did not cite the " | |
| f"expected error {expected_error!r}; stdout: {result['stdout']!r}" | |
| ) | |
| @pytest.mark.parametrize( | |
| "payload", | |
| [ | |
| pytest.param(b"\x00\x01\x02\xff\xfe\xfd\x7f\x80\x81\x82", id="binary_only"), | |
| pytest.param(b"G\xffT /openapi HTTP/1.1\r\n\r\n", id="binary_in_method"), | |
| pytest.param(b"GET /open\x00api HTTP/1.1\r\n\r\n", id="binary_in_url"), | |
| pytest.param(b"GET /openapi HTTP/1.\xff\r\n\r\n", id="binary_in_version"), | |
| pytest.param( | |
| b"GET /openapi HTTP/1.1\r\n\x01Foo: bar\r\n\r\n", | |
| id="binary_in_header_name", | |
| ), | |
| pytest.param( | |
| b"GET /openapi HTTP/1.1\r\nFoo: ba\x01r\r\n\r\n", | |
| id="binary_in_header_value", | |
| ), | |
| pytest.param( | |
| b"GET /openapi HTTP/1.1\r\nFoo: bar\r\n\xff\xfe\r\n\r\n", | |
| id="binary_between_headers", | |
| ), | |
| pytest.param( | |
| b"GET /openapi HTTP/1.1\r\n\r\n\xde\xad\xbe\xef", | |
| id="binary_after_headers_no_clen", | |
| ), | |
| pytest.param( | |
| b"GET /openapi HTTP/1.1\r\nHost: x\r\n\r\n" | |
| b"\xff\xfe\xfd" | |
| b"GET /openapi HTTP/1.1\r\n\r\n", | |
| id="binary_between_pipelined", | |
| ), | |
| ], | |
| ) | |
| def test_bad_binary_input(payload): | |
| """Verify slurmrestd rejects requests containing arbitrary binary bytes. | |
| Binary payloads cannot round-trip through atf's text-mode subprocess | |
| pipe, so we base64-encode them and decode in the shell into slurmrestd's | |
| stdin. base64 is ASCII-safe and the decode happens in the same shell that | |
| runs slurmrestd, so the bytes survive intact even when the command runs as | |
| the test user. | |
| """ | |
| b64 = base64.b64encode(payload).decode("ascii") | |
| result = atf.run_command( | |
| f"echo {b64} | base64 -d | slurmrestd -a rest_auth/local", xfail=True | |
| ) | |
| assert ( | |
| result["exit_code"] != 0 | |
| ), f"slurmrestd should have rejected binary payload: {payload!r}" | |
| assert ( | |
| "Assertion" not in result["stderr"] | |
| ), f"slurmrestd aborted instead of cleanly rejecting {payload!r}: {result['stderr']!r}" | |
| @pytest.mark.parametrize( | |
| "payload", | |
| [ | |
| pytest.param( | |
| "GET /openapi HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", | |
| id="get_with_host", | |
| ), | |
| pytest.param("GET /openapi HTTP/1.0\r\n\r\n", id="get_http_1_0"), | |
| pytest.param( | |
| "GET /openapi?key=val HTTP/1.1\r\nConnection: close\r\n\r\n", | |
| id="get_with_query", | |
| ), | |
| pytest.param("GET /openapi HTTP/1.1\r\n\r\n", id="get_no_headers"), | |
| ], | |
| ) | |
| def test_valid_input(payload): | |
| """Verify slurmrestd accepts simple well-formed HTTP requests.""" | |
| output = atf.run_command_output( | |
| "slurmrestd -a rest_auth/local", input=payload, fatal=True | |
| ) | |
| assert any( | |
| line.startswith("HTTP/") and " 200 " in line for line in output.splitlines() | |
| ), f"slurmrestd should have returned an HTTP 200 for well-formed request {payload!r}, got stdout: {output!r}" | |
| def test_keep_alive_two_requests(): | |
| """Verify slurmrestd handles two pipelined requests on one connection. | |
| Without an explicit Connection: close, HTTP/1.1 keeps the connection | |
| open until stdin EOF, so slurmrestd must parse both messages and emit | |
| two responses. | |
| """ | |
| payload = ( | |
| "GET /openapi HTTP/1.1\r\nHost: localhost\r\n\r\n" | |
| "GET /openapi HTTP/1.1\r\nHost: localhost\r\n\r\n" | |
| ) | |
| output = atf.run_command_output( | |
| "slurmrestd -a rest_auth/local", input=payload, fatal=True | |
| ) | |
| # Pipelined responses are concatenated with no separator, so the second | |
| # status line is not newline-anchored; match it as a substring instead. | |
| responses = len(re.findall(r"HTTP/\d+\.\d+ 200\b", output)) | |
| assert ( | |
| responses >= 2 | |
| ), f"Expected two HTTP 200 responses from pipelined requests, got stdout: {output!r}" |