Files
codex/scripts/install/test_install_sh.py
efrazer-oai dc23c7bcc8 fix: parse compact release metadata in installer (#31667)
# Summary

GitHub's latest-release endpoint can return compact, single-line JSON.
The standalone installer treated release metadata as line-oriented text,
so those responses could make asset lookup fail even though the
requested assets were present.

The regression was introduced by
[#31056](https://github.com/openai/codex/pull/31056). That change reused
the `/releases/latest` metadata response for both version resolution and
asset lookup, exposing the existing formatting-sensitive asset parser to
compact responses from that endpoint.

This change parses the release metadata once with a one-pass POSIX awk
scanner. The scanner tracks JSON strings and nesting, extracts the root
release tag plus direct asset name/digest pairs, and produces the same
result regardless of whitespace or object field order. It uses POSIX
`fold` to bound awk record sizes so compact responses stay fast across
awk implementations.

Fixes #31520.

## Changes

- replace line-oriented release metadata matching with structure-aware
parsing
- reuse the parsed metadata for latest-version and asset-digest lookup
- add regression coverage for compact JSON, reordered fields, nested
decoys, and JSON-looking release text

## Design decisions

- Keep the installer dependency-free by using standard POSIX tools
already required by the shell installer.
- Parse only the GitHub release fields the installer consumes, in one
pass, instead of vendoring a general JSON library.
- Preserve asset-object boundaries so nested or string-encoded `name`
and `digest` fields cannot be mistaken for release assets.

## Testing

- Tests: focused installer suite locally and on Linux.
- Smoke tests: real pretty and compact GitHub release metadata,
latest-release resolution, and checksum-asset selection.
- Portability: macOS awk plus Linux gawk, mawk, and nawk.
- Stress coverage: randomized formatting and field order, adversarial
nested/string content, and a synthetic 2,000-asset compact response.
2026-07-08 22:08:00 -07:00

226 lines
7.1 KiB
Python

#!/usr/bin/env python3
import json
import os
from pathlib import Path
import subprocess
import tempfile
import textwrap
import unittest
INSTALL_SCRIPT = Path(__file__).with_name("install.sh")
VERSION = "0.142.5"
class InstallShTest(unittest.TestCase):
def test_metadata_fetch_failure_is_not_reported_as_missing_assets(self) -> None:
result, requests = run_installer(VERSION, metadata_failure=True)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(
requests,
[
"https://api.github.com/repos/openai/codex/releases/tags/"
f"rust-v{VERSION}"
],
)
self.assertIn(
f"Could not fetch GitHub release metadata for Codex {VERSION}",
result.stderr,
)
self.assertNotIn("Could not find Codex package", result.stderr)
def test_exact_release_fetches_metadata_once(self) -> None:
result, requests = run_installer(VERSION)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(
requests,
[
"https://api.github.com/repos/openai/codex/releases/tags/"
f"rust-v{VERSION}",
"https://github.com/openai/codex/releases/download/"
f"rust-v{VERSION}/codex-package_SHA256SUMS",
],
)
self.assertIn(f"Resolved version: {VERSION}", result.stdout)
def test_latest_release_reuses_version_metadata(self) -> None:
result, requests = run_installer("latest")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(
requests,
[
"https://api.github.com/repos/openai/codex/releases/latest",
"https://github.com/openai/codex/releases/download/"
f"rust-v{VERSION}/codex-package_SHA256SUMS",
],
)
self.assertIn(f"Resolved version: {VERSION}", result.stdout)
def test_compact_metadata_is_independent_of_field_order(self) -> None:
result, requests = run_installer(
"latest", metadata_json=release_metadata(compact=True, reorder=True)
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(
requests,
[
"https://api.github.com/repos/openai/codex/releases/latest",
"https://github.com/openai/codex/releases/download/"
f"rust-v{VERSION}/codex-package_SHA256SUMS",
],
)
self.assertIn(f"Resolved version: {VERSION}", result.stdout)
def test_json_like_strings_and_nested_fields_do_not_define_assets(self) -> None:
result, requests = run_installer(
VERSION, metadata_json=legacy_release_metadata_with_decoys()
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(len(requests), 2)
self.assertIn("/codex-npm-", requests[1])
self.assertNotIn("codex-package_SHA256SUMS", requests[1])
def run_installer(
release: str,
*,
metadata_failure: bool = False,
metadata_json: str | None = None,
) -> tuple[subprocess.CompletedProcess[str], list[str]]:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
bin_dir = root / "bin"
bin_dir.mkdir()
request_log = root / "requests.log"
fake_curl = bin_dir / "curl"
fake_curl.write_text(
textwrap.dedent(
"""\
#!/bin/sh
url=""
for arg in "$@"; do
case "$arg" in
https://*) url="$arg" ;;
esac
done
printf '%s\n' "$url" >>"$CODEX_TEST_REQUEST_LOG"
case "$url" in
https://api.github.com/*)
if [ "$CODEX_TEST_METADATA_FAILURE" = "1" ]; then
echo "curl: (22) The requested URL returned error: 403" >&2
exit 22
fi
printf '%s\n' "$CODEX_TEST_METADATA_JSON"
;;
*)
exit 22
;;
esac
"""
),
encoding="utf-8",
)
fake_curl.chmod(0o755)
env = os.environ.copy()
env.update(
{
"CODEX_HOME": str(root / "codex-home"),
"CODEX_INSTALL_DIR": str(root / "install-bin"),
"CODEX_NON_INTERACTIVE": "1",
"CODEX_RELEASE": release,
"CODEX_TEST_METADATA_FAILURE": "1" if metadata_failure else "0",
"CODEX_TEST_METADATA_JSON": (
metadata_json if metadata_json is not None else release_metadata()
),
"CODEX_TEST_REQUEST_LOG": str(request_log),
"HOME": str(root / "home"),
"PATH": f"{bin_dir}:/usr/bin:/bin",
"SHELL": "/bin/sh",
}
)
result = subprocess.run(
["/bin/sh", str(INSTALL_SCRIPT)],
capture_output=True,
check=False,
env=env,
text=True,
)
requests = (
request_log.read_text(encoding="utf-8").splitlines()
if request_log.exists()
else []
)
return result, requests
def release_metadata(*, compact: bool = False, reorder: bool = False) -> str:
assets = [
asset_metadata(
f"codex-package-{target}.tar.gz",
f"sha256:{'a' * 64}",
reorder=reorder,
)
for target in (
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-musl",
"x86_64-unknown-linux-musl",
)
]
assets.append(
asset_metadata(
"codex-package_SHA256SUMS",
f"sha256:{'b' * 64}",
reorder=reorder,
)
)
separators = (",", ":") if compact else None
return json.dumps(
{"assets": assets, "body": "braces: { } [ ]", "tag_name": f"rust-v{VERSION}"},
indent=None if compact else 2,
separators=separators,
)
def asset_metadata(name: str, digest: str, *, reorder: bool) -> dict[str, str]:
if reorder:
return {"digest": digest, "name": name}
return {"name": name, "digest": digest}
def legacy_release_metadata_with_decoys() -> str:
fake_digest = f"sha256:{'0' * 64}"
assets = [
{
"metadata": {
"name": "codex-package-x86_64-unknown-linux-musl.tar.gz",
"digest": fake_digest,
},
"digest": f"sha256:{'c' * 64}",
"name": f"codex-npm-{target}-{VERSION}.tgz",
}
for target in ("darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64")
]
return json.dumps(
{
"body": (
f'fake: {{"name":"codex-package_SHA256SUMS","digest":"{fake_digest}"}}'
),
"assets": assets,
"tag_name": f"rust-v{VERSION}",
},
separators=(",", ":"),
)
if __name__ == "__main__":
unittest.main()