Phase 1 polish: PDU match test catches a real capability-int bug

Polish item #1: byte-for-byte regression test that asserts our
generated login PDU is structurally identical to JDBC's reference
captured in docs/CAPTURES/01-connect-only.socat.log.

The test (tests/test_pdu_match.py) immediately caught a real bug:
the capability section was misread during Phase 0 byte-decoding.
Earlier text claimed Cap_1=1, Cap_2=0x3c000000, Cap_3=0 — actually:

  Cap_1 = 0x0000013c   (= (capability_class << 8) | protocol_version
                          where protocol_version = 0x3c = PF_PROT_SQLI_0600)
  Cap_2 = 0
  Cap_3 = 0

The misalignment was: the 0x3c byte I attributed to Cap_2's high
byte was actually Cap_1's low byte. The dev-image server is
permissive enough to accept arbitrary capability values, so the
connection succeeded even with the wrong bytes — but the PDU wasn't
structurally identical to JDBC's reference. SERVER-ACCEPTS ≠
STRUCTURALLY-CORRECT. This is exactly why the byte-for-byte diff
was the right polish item; "it connects" was a false ceiling.

After fix:
- 6 PDU-match tests assert byte-for-byte equality at offsets 2..280
  (the structural prefix: SLheader sans length, all login markers,
  capability ints, username, password, protocol IDs, env vars).
- Bytes 280+ legitimately differ per process (PID, TID, hostname,
  cwd, AppName) — those are NOT asserted.
- Length field (offsets 0..1) also legitimately differs because our
  PDU has shorter env list and AppName.
- Test uses monkey-patched IfxSocket so no network is needed.

Polish item #2: Makefile per global CLAUDE.md convention. Targets:
install, lint, format, test, test-integration, test-all, test-pdu,
ifx-up/down/logs/shell/status, capture (re-run JDBC scenarios under
socat), clean. `make` (no target) prints help.

Doc updates:
- PROTOCOL_NOTES.md §12: corrected capability section with the
  actual values and an explanation of the methodology lesson
- DECISION_LOG.md: new entry recording the correction with a
  pointer to the regression test and the takeaway

Side artifacts:
- docs/CAPTURES/03-py-connect-only.socat.log
- docs/CAPTURES/04-py-no-database.socat.log
- docs/CAPTURES/05-py-fixed-caps.socat.log

Test counts: 40 unit + 6 integration = 46 total, all green, ruff clean.
This commit is contained in:
Ryan Malloy 2026-05-02 20:18:03 -06:00
parent 9b1fd8af2c
commit ea00990774
8 changed files with 317 additions and 6 deletions

91
Makefile Normal file
View File

@ -0,0 +1,91 @@
# informix-db — common dev commands
#
# uv-managed; run `make help` for the full target list.
# Image digest pinned in tests/docker-compose.yml; mirrored here for
# tab-complete-able commands like `make ifx-logs` that don't go through
# docker-compose.
IFX_CONTAINER ?= informix-db-test
.PHONY: help install lint format test test-integration test-all test-pdu \
ifx-up ifx-down ifx-logs ifx-shell ifx-status \
capture clean
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
| awk -F':.*## ' '{printf " %-20s %s\n", $$1, $$2}'
# ----------------------------------------------------------------------------
# Python / dev workflow
# ----------------------------------------------------------------------------
install: ## Sync dev dependencies (uv sync --extra dev)
uv sync --extra dev
lint: ## Run ruff
uv run ruff check src/ tests/
format: ## Auto-format with ruff
uv run ruff format src/ tests/
uv run ruff check src/ tests/ --fix
test: ## Run unit tests (no Docker required)
uv run pytest
test-integration: ## Run integration tests (needs Informix container; see `make ifx-up`)
uv run pytest -m integration
test-all: ## Run unit + integration tests
uv run pytest -m ""
test-pdu: ## Run only the JDBC-vs-Python PDU regression test
uv run pytest tests/test_pdu_match.py -v
# ----------------------------------------------------------------------------
# Informix dev container
# ----------------------------------------------------------------------------
ifx-up: ## Start the Informix dev container (pinned by digest)
docker compose -f tests/docker-compose.yml up -d
@echo " Container: $(IFX_CONTAINER)"
@echo " Listener: 127.0.0.1:9088 (SQLI native)"
@echo " Login: informix / in4mix on database sysmaster"
ifx-down: ## Stop and remove the Informix container
docker compose -f tests/docker-compose.yml down
ifx-logs: ## Tail the container logs
docker logs -f $(IFX_CONTAINER)
ifx-shell: ## Drop into a shell inside the container
docker exec -it $(IFX_CONTAINER) bash
ifx-status: ## Check container health and listener readiness
@docker ps --filter name=$(IFX_CONTAINER) --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
@nc -zv 127.0.0.1 9088 2>&1 | head -1
# ----------------------------------------------------------------------------
# Phase 0 spike: re-capture wire traffic against the dev container
# ----------------------------------------------------------------------------
capture: ## Re-capture all three reference scenarios (JDBC) under socat
@for s in connect-only select-1 dml-cycle; do \
echo "=== $$s ==="; \
socat -d -d -x TCP-LISTEN:9090,reuseaddr TCP:127.0.0.1:9088 \
2>"docs/CAPTURES/$$s.socat.log" & \
SOCAT_PID=$$!; \
sleep 0.4; \
IFX_PORT=9090 java -cp "build/ifxjdbc.jar:build/" tests.reference.RefClient $$s; \
sleep 0.3; \
kill $$SOCAT_PID 2>/dev/null; \
wait 2>/dev/null; \
echo " → docs/CAPTURES/$$s.socat.log"; \
done
# ----------------------------------------------------------------------------
# Cleanup
# ----------------------------------------------------------------------------
clean: ## Remove build artifacts and caches (keeps captures and decompiled JDBC source)
rm -rf dist/ .pytest_cache/ .ruff_cache/ .mypy_cache/
find src tests -name __pycache__ -type d -exec rm -rf {} +

View File

@ -0,0 +1,16 @@
2026/05/02 20:10:06 socat[3937281] N listening on AF=2 0.0.0.0:9090
2026/05/02 20:10:07 socat[3937281] N accepting connection from AF=2 127.0.0.1:59562 on AF=2 127.0.0.1:9090
2026/05/02 20:10:07 socat[3937281] N opening connection to 127.0.0.1:9088
2026/05/02 20:10:07 socat[3937281] N opening connection to AF=2 127.0.0.1:9088
2026/05/02 20:10:07 socat[3937281] N successfully connected from local address AF=2 127.0.0.1:39822
2026/05/02 20:10:07 socat[3937281] N successfully connected to 127.0.0.1:9088
2026/05/02 20:10:07 socat[3937281] N starting data transfer loop with FDs [6,6] and [5,5]
> 2026/05/02 20:10:07.218292 length=395 from=0 to=394
01 8b 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 00 01 3c 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 0a 73 79 73 6d 61 73 74 65 72 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 3c 14 0d 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 21 00 00 00 00 00 00 00 00 00 17 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 39 33 37 32 39 33 00 00 7f
< 2026/05/02 20:10:07.221921 length=276 from=0 to=275
01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 1d 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f
> 2026/05/02 20:10:07.222169 length=2 from=395 to=396
00 38
2026/05/02 20:10:07 socat[3937281] N socket 2 (fd 5) is at EOF
2026/05/02 20:10:07 socat[3937281] N socket 1 (fd 6) is at EOF
2026/05/02 20:10:07 socat[3937281] N exiting with status 0

View File

@ -0,0 +1,16 @@
2026/05/02 20:11:28 socat[3940306] N listening on AF=2 0.0.0.0:9090
2026/05/02 20:11:29 socat[3940306] N accepting connection from AF=2 127.0.0.1:47930 on AF=2 127.0.0.1:9090
2026/05/02 20:11:29 socat[3940306] N opening connection to 127.0.0.1:9088
2026/05/02 20:11:29 socat[3940306] N opening connection to AF=2 127.0.0.1:9088
2026/05/02 20:11:29 socat[3940306] N successfully connected from local address AF=2 127.0.0.1:38044
2026/05/02 20:11:29 socat[3940306] N successfully connected to 127.0.0.1:9088
2026/05/02 20:11:29 socat[3940306] N starting data transfer loop with FDs [6,6] and [5,5]
> 2026/05/02 20:11:29.010247 length=385 from=0 to=384
01 81 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 00 01 3c 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 3c 1f de 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 21 00 00 00 00 00 00 00 00 00 17 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 39 34 30 33 31 38 00 00 7f
< 2026/05/02 20:11:29.022216 length=276 from=0 to=275
01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 1c 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f
> 2026/05/02 20:11:29.022563 length=2 from=385 to=386
00 38
2026/05/02 20:11:29 socat[3940306] N socket 2 (fd 5) is at EOF
2026/05/02 20:11:29 socat[3940306] N socket 1 (fd 6) is at EOF
2026/05/02 20:11:29 socat[3940306] N exiting with status 0

View File

@ -0,0 +1,16 @@
2026/05/02 20:15:11 socat[3950551] N listening on AF=2 0.0.0.0:9090
2026/05/02 20:15:11 socat[3950551] N accepting connection from AF=2 127.0.0.1:50082 on AF=2 127.0.0.1:9090
2026/05/02 20:15:11 socat[3950551] N opening connection to 127.0.0.1:9088
2026/05/02 20:15:11 socat[3950551] N opening connection to AF=2 127.0.0.1:9088
2026/05/02 20:15:11 socat[3950551] N successfully connected from local address AF=2 127.0.0.1:44592
2026/05/02 20:15:11 socat[3950551] N successfully connected to 127.0.0.1:9088
2026/05/02 20:15:11 socat[3950551] N starting data transfer loop with FDs [6,6] and [5,5]
> 2026/05/02 20:15:11.643448 length=385 from=0 to=384
01 81 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 3c 47 e2 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 21 00 00 00 00 00 00 00 00 00 17 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 39 35 30 35 36 32 00 00 7f
< 2026/05/02 20:15:11.655455 length=276 from=0 to=275
01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 14 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f
> 2026/05/02 20:15:11.655744 length=2 from=385 to=386
00 38
2026/05/02 20:15:11 socat[3950551] N socket 2 (fd 5) is at EOF
2026/05/02 20:15:11 socat[3950551] N socket 1 (fd 6) is at EOF
2026/05/02 20:15:11 socat[3950551] N exiting with status 0

View File

@ -158,6 +158,16 @@ DATETIME / INTERVAL / DECIMAL / NUMERIC / MONEY remain in Phase 6+ — their enc
--- ---
## 2026-05-02 — Capability ints: corrected after PDU diff caught misread
**Status**: active (corrects an earlier same-day entry)
**Decision**: Send `Cap_1 = 0x0000013c, Cap_2 = 0, Cap_3 = 0` in the binary login PDU. These are the values IBM's JDBC driver sends; the server echoes them back identically.
**Why this is a correction**: An earlier read of the wire bytes (before we wrote the byte-for-byte PDU diff) decoded the capability section as `Cap_1=1, Cap_2=0x3c000000, Cap_3=0`. That was a misalignment — the `0x3c` byte interpreted as `Cap_2`'s high byte was actually `Cap_1`'s low byte. Real layout: a single int `0x0000013c` = `(capability_class << 8) | PF_PROT_SQLI_0600 (60 = 0x3c)`.
**How we caught it**: `tests/test_pdu_match.py` — captures our generated PDU via a monkey-patched socket and asserts byte-for-byte equality against `docs/CAPTURES/01-connect-only.socat.log` for offsets 2..280 (the structural prefix). The connection still worked with the wrong values because the dev image is permissive, but the PDU was structurally non-identical. **Server-accepts ≠ structurally-correct.**
**Methodology takeaway**: For wire-protocol implementations, always diff against the reference vendor's PDU bytes, not just "it connected." Permissive servers mask real bugs.
---
## (template — copy below this line for new entries) ## (template — copy below this line for new entries)
``` ```

Binary file not shown.

View File

@ -38,12 +38,17 @@ from ._protocol import IfxStreamReader, IfxStreamWriter, ProtocolError, make_pdu
from ._socket import IfxSocket from ._socket import IfxSocket
from .exceptions import InterfaceError, OperationalError from .exceptions import InterfaceError, OperationalError
# Default capability bits the JDBC reference sends. Captured from # Default capability bits the JDBC reference sends. Validated against
# 01-connect-only.socat.log: Cap_1=1, Cap_2=0x3c000000, Cap_3=0. # 01-connect-only.socat.log via the PDU diff in tests/test_pdu_match.py:
# Server echoes these back. Their exact bit semantics are unmapped; for # Cap_1 = 0x0000013c = 316 — appears to be (capability_class << 8) | protocol_version,
# now we send what JDBC sends so the server treats us as a known peer. # where protocol_version = 0x3c = PF_PROT_SQLI_0600 (=60)
_DEFAULT_CAP_1 = 1 # Cap_2 = 0
_DEFAULT_CAP_2 = 0x3C000000 # Cap_3 = 0
# Server echoes these back in DecodeAscBinary. The dev image is permissive
# and accepts other values too, but matching JDBC's reference protects us
# against subtle compatibility issues with stricter server configurations.
_DEFAULT_CAP_1 = 0x0000013C
_DEFAULT_CAP_2 = 0
_DEFAULT_CAP_3 = 0 _DEFAULT_CAP_3 = 0
# Default environment variables sent in the login PDU (SQ_ASCENV section). # Default environment variables sent in the login PDU (SQ_ASCENV section).

157
tests/test_pdu_match.py Normal file
View File

@ -0,0 +1,157 @@
"""Regression test: our generated login PDU is byte-identical to JDBC's.
Phase 1 polish artifact. We monkeypatch ``IfxSocket`` with a fake that
captures the bytes we send, then compare those bytes to the captured
JDBC reference PDU in ``docs/CAPTURES/01-connect-only.socat.log``.
Bytes 2..280 of the PDU are the *structural* prefix SLheader (sans
length field), all login markers, the three capability ints, username,
password, protocol identifiers, and environment variables. These MUST
be byte-identical to JDBC's PDU; any divergence is a real bug (we
caught one this way already the misaligned capability ints).
Bytes 280+ contain process-specific fields (PID, thread ID, hostname,
cwd, AppName) that legitimately differ per Python process. The test
asserts only the structural prefix.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import informix_db
from informix_db import connections
def _extract_first_client_pdu(log_path: Path) -> bytes:
"""Pull the first '>' (client→server) hex dump out of a socat -x log."""
text = log_path.read_text()
match = re.search(r"^> .*?length=\d+.*?\n (.*?)\n", text, re.MULTILINE | re.DOTALL)
assert match, f"no client→server message found in {log_path}"
return bytes.fromhex(match.group(1).strip().replace(" ", ""))
@pytest.fixture
def jdbc_reference_pdu() -> bytes:
"""The IBM JDBC reference login PDU, captured under socat in Phase 0."""
return _extract_first_client_pdu(
Path(__file__).parent.parent / "docs/CAPTURES/01-connect-only.socat.log"
)
@pytest.fixture
def python_login_pdu(monkeypatch: pytest.MonkeyPatch) -> bytes:
"""Capture the bytes our pure-Python client emits without touching the network."""
captured = bytearray()
class _CapturingSocket:
"""Fake socket: captures writes, then raises to stop the connect flow."""
def __init__(self, *_args: object, **_kwargs: object) -> None:
self._closed = False
@property
def closed(self) -> bool:
return self._closed
def write_all(self, data: bytes) -> None:
captured.extend(data)
# Stop the connect flow before it tries to read a server response.
raise informix_db.OperationalError("stub: stop after login PDU")
def read_exact(self, _n: int) -> bytes:
raise informix_db.OperationalError("stub: never reached")
def close(self) -> None:
self._closed = True
monkeypatch.setattr(connections, "IfxSocket", _CapturingSocket)
with pytest.raises(informix_db.OperationalError, match="stub"):
informix_db.connect(
host="dont.care", port=9088,
user="informix", password="in4mix",
database=None, server="informix",
)
return bytes(captured)
# ---------------------------------------------------------------------------
# Structural-prefix tests
# ---------------------------------------------------------------------------
# Offset where process-specific fields begin (PID/TID/hostname/cwd/AppName).
# Empirically determined by running the diff after fixing the caps ints
# (see DECISION_LOG.md). Anything before this MUST match byte-for-byte.
STRUCTURAL_PREFIX_END = 280
def test_slheader_protocol_version_matches(
python_login_pdu: bytes, jdbc_reference_pdu: bytes
) -> None:
"""The SLheader's protocol-version byte (offset 3) must be 60 (PF_PROT_SQLI_0600)."""
assert python_login_pdu[3] == jdbc_reference_pdu[3] == 0x3C
def test_slheader_type_byte_matches(
python_login_pdu: bytes, jdbc_reference_pdu: bytes
) -> None:
"""The SLheader's slType byte (offset 2) must be 1 (SLTYPE_CONREQ)."""
assert python_login_pdu[2] == jdbc_reference_pdu[2] == 0x01
def test_capability_ints_match_reference(
python_login_pdu: bytes, jdbc_reference_pdu: bytes
) -> None:
"""Cap_1 / Cap_2 / Cap_3 (offsets 65..76) must be byte-identical to JDBC.
This is the test that would have caught the original capability-int bug
(where we sent caps_1=1, caps_2=0x3c000000 instead of caps_1=0x13c, caps_2=0).
"""
assert python_login_pdu[65:77] == jdbc_reference_pdu[65:77]
def test_structural_prefix_matches(
python_login_pdu: bytes, jdbc_reference_pdu: bytes
) -> None:
"""Everything from byte 2 to ``STRUCTURAL_PREFIX_END`` must match exactly.
Skips:
* Bytes 0..1 (SLheader length): differs because Python sends fewer
env vars / shorter AppName, so total length differs.
* Bytes ``STRUCTURAL_PREFIX_END``..end: process-specific fields
(PID, TID, hostname, cwd, AppName).
"""
py_prefix = python_login_pdu[2:STRUCTURAL_PREFIX_END]
ja_prefix = jdbc_reference_pdu[2:STRUCTURAL_PREFIX_END]
if py_prefix != ja_prefix:
# Find first divergence and report it with context.
for i, (a, b) in enumerate(zip(py_prefix, ja_prefix, strict=False)):
if a != b:
off = i + 2
pytest.fail(
f"structural-prefix mismatch at offset {off}: "
f"Python={a:#04x} JDBC={b:#04x}\n"
f" Python[{off - 4}..{off + 4}]: "
f"{python_login_pdu[off - 4:off + 5].hex(' ')}\n"
f" JDBC [{off - 4}..{off + 4}]: "
f"{jdbc_reference_pdu[off - 4:off + 5].hex(' ')}"
)
assert py_prefix == ja_prefix
def test_pdu_is_correctly_length_prefixed(python_login_pdu: bytes) -> None:
"""The SLheader's first 2 bytes must equal the total PDU length."""
declared_length = int.from_bytes(python_login_pdu[0:2], "big", signed=False)
assert declared_length == len(python_login_pdu)
def test_pdu_ends_with_sq_asceot(python_login_pdu: bytes) -> None:
"""Every login PDU must end with [short SQ_ASCEOT=127] (= 0x00 0x7f)."""
assert python_login_pdu[-2:] == b"\x00\x7f"