fix(gateway): reject artifact archives where a file collides with an implicit directory (#7977) (#8232)

* fix(gateway): reject artifact archives where a file collides with an implicit directory

When a run outputs both a file and a deeper file path whose parent directory
matches the first file's normalized name (e.g. 'Report' and
'report/slides/lesson.pptx', or Unicode NFC/NFD variations), the ZIP archive
builder allowed both entries because it only checked for exact full path
equality. On case-insensitive filesystems, extracting such archives fails because
the file entry blocks directory creation or vice versa.

This change collects all implicit directory prefixes from normalized casefolded
entries and rejects the archive with HTTP 409 (ArtifactArchiveError) if any file
entry name collides with an implicit directory, regardless of receipt order.

Fixes #7977

* fix(gateway): address review feedback on test portability and loop readability
This commit is contained in:
Arif Rahman 2026-11-15 19:28:29 +07:00 committed by GitHub
parent e4b2f4cb9a
commit 9134cf26c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 77 additions and 1 deletions

View File

@ -243,7 +243,15 @@ def build_artifact_archive(
reserved = frozenset(name.casefold() for name in {BROWSER_FRAMES_DIRNAME, TOOL_RESULTS_DIRNAME, *extra_reserved_dir_names})
members = [_member(root, path, reserved, deadline, root_components) for path in paths]
collision_keys = [unicodedata.normalize("NFC", member.entry).casefold() for member in members]
if len(collision_keys) != len(set(collision_keys)):
collision_set = set(collision_keys)
if len(collision_keys) != len(collision_set):
raise _reject()
implicit_dirs = set()
for key in collision_keys:
parts = key.split("/")
for i in range(1, len(parts)):
implicit_dirs.add("/".join(parts[:i]))
if collision_set & implicit_dirs:
raise _reject()
sizes = [member.initial.st_size for member in members]

View File

@ -407,6 +407,74 @@ def test_archive_rejects_casefolded_entry_collisions(tmp_path, monkeypatch) -> N
assert response.status_code == 409
@pytest.mark.parametrize(
"order",
[
["/mnt/user-data/outputs/Report", "/mnt/user-data/outputs/report/slides/lesson.pptx"],
["/mnt/user-data/outputs/report/slides/lesson.pptx", "/mnt/user-data/outputs/Report"],
],
)
def test_archive_rejects_file_colliding_with_implicit_directory(tmp_path, monkeypatch, order) -> None:
outputs = tmp_path / "outputs"
outputs.mkdir()
file_entry = outputs / "Report"
file_entry.write_text("file content", encoding="utf-8")
lower_report = outputs / "report"
if lower_report.exists() and file_entry.samefile(lower_report):
pytest.skip("case-sensitive filenames are unavailable on this filesystem")
nested_dir = outputs / "report" / "slides"
try:
nested_dir.mkdir(parents=True)
except (NotADirectoryError, FileExistsError):
pytest.skip("case-sensitive filenames are unavailable on this filesystem")
nested_entry = nested_dir / "lesson.pptx"
nested_entry.write_text("slides content", encoding="utf-8")
client, _, _ = _archive_app(
monkeypatch,
outputs,
paths=order,
)
with client:
response = client.post(ARCHIVE_URL)
assert response.status_code == 409
def test_archive_rejects_unicode_nfc_nfd_implicit_directory_collision(tmp_path, monkeypatch) -> None:
outputs = tmp_path / "outputs"
outputs.mkdir()
nfc_name = "Caf\u00e9"
nfd_dir = "Cafe\u0301"
file_entry = outputs / nfc_name
file_entry.write_text("cafe content", encoding="utf-8")
nfd_path = outputs / nfd_dir
if nfd_path.exists() and file_entry.samefile(nfd_path):
pytest.skip("normalization-sensitive filenames are unavailable on this filesystem")
nested_dir = outputs / nfd_dir
try:
nested_dir.mkdir(parents=True)
except (NotADirectoryError, FileExistsError):
pytest.skip("normalization-sensitive filenames are unavailable on this filesystem")
nested_entry = nested_dir / "menu.txt"
nested_entry.write_text("menu content", encoding="utf-8")
client, _, _ = _archive_app(
monkeypatch,
outputs,
paths=[
f"/mnt/user-data/outputs/{nfc_name}",
f"/mnt/user-data/outputs/{nfd_dir}/menu.txt",
],
)
with client:
response = client.post(ARCHIVE_URL)
assert response.status_code == 409
def test_archive_rechecks_size_after_open(tmp_path, monkeypatch) -> None:
outputs = tmp_path / "outputs"
outputs.mkdir()